diff --git a/package-firewall/powershell/lib/common.ps1 b/package-firewall/powershell/lib/common.ps1 index b456bed..af7efbb 100644 --- a/package-firewall/powershell/lib/common.ps1 +++ b/package-firewall/powershell/lib/common.ps1 @@ -23,6 +23,8 @@ # VS Code (product.json) — see the "VS Code" section at the bottom of this file: # Get-EndorB64Url / Get-EndorB64Decode — base64url encode / decode # *-Json* — depth-1 JSON editors that preserve layout +# Get-VSCodeInstallPath / Get-VSCodeManagedState / Invoke-VSCodePatch / Invoke-VSCodeUnpatch +# Install-VSCodeWatcher / Uninstall-VSCodeWatcher # ╔══════════════════════════════════════════════════════════════════════╗ # ║ SENTINEL CONTRACT — DO NOT CHANGE THESE STRINGS ║ @@ -49,6 +51,10 @@ $ENDOR_XML_BLOCK_END = '' # ╚══════════════════════════════════════════════════════════════════════╝ $ENDOR_JSON_MARKER_KEY = '_endorPackageFirewall' +# Scheduled Task identity for the product.json re-apply watcher. +$ENDOR_VSCODE_TASK_PATH = '\Endor\' +$ENDOR_VSCODE_TASK_NAME = 'PackageFirewall-VSCode' + # ── User attribution helpers ────────────────────────────────────────────────── # Encode @ into the Basic-auth username. The firewall # decodes the label, auths with the real API key, and logs it as "User". @@ -650,9 +656,8 @@ function Test-XmlKeyConflict { # re-indent everything, needs -Depth raised on 5.1 (default 2 silently truncates), # and escapes forward slashes — turning a two-line change into a whole-file # rewrite that no reviewer can diff. Shipped product.json is pretty-printed one -# entry per line, so a depth-1 line range is unambiguous. On anything else these -# editors decline — they return $null and leave the file untouched rather than -# guessing — so a caller can fall back to a real JSON parser. +# entry per line, so a depth-1 line range is unambiguous. Anything else falls +# through to Invoke-VSCodePatchViaNode. # ══════════════════════════════════════════════════════════════════════════════ # Get-JsonDoc @@ -891,3 +896,489 @@ function Test-JsonValid { return $true } catch { return $false } } + +# ── Install discovery ───────────────────────────────────────────────────────── + +# Get-VSCodeInstallPath [-UserHome ] [-Roots ] +# One product.json path per VS Code install found, stable and Insiders. +# +# NOTE the per-user candidates use $UserHome, NOT $env:LOCALAPPDATA: Intune runs +# scripts as SYSTEM, whose LOCALAPPDATA is under C:\Windows, so relying on the +# env var would silently miss every per-user install on the fleet. +function Get-VSCodeInstallPath { + param([string]$UserHome, [string[]]$Roots) + + if (-not $Roots) { + $bases = @() + foreach ($pf in @($env:ProgramW6432, $env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ($pf) { $bases += $pf } + } + if ($UserHome) { $bases += (Join-Path $UserHome 'AppData\Local\Programs') } + $Roots = @() + foreach ($b in ($bases | Select-Object -Unique)) { + $Roots += (Join-Path $b 'Microsoft VS Code') + $Roots += (Join-Path $b 'Microsoft VS Code Insiders') + } + } + + $found = @() + foreach ($r in $Roots) { + $pj = Join-Path $r 'resources\app\product.json' + if (Test-Path -LiteralPath $pj -PathType Leaf) { $found += $pj } + } + @($found | Select-Object -Unique) +} + +# Get-VSCodeEditionLabel — human label for logs. +function Get-VSCodeEditionLabel { + param([string]$FilePath) + try { + $name = Get-JsonTopString -Lines (Get-JsonDoc $FilePath).Lines -Key 'nameLong' + if ($name) { return $name } + } catch { } + 'VS Code' +} + +# Get-VSCodeInstallRoot — the directory containing resources\. +function Get-VSCodeInstallRoot { + param([string]$FilePath) + Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $FilePath)) +} + +# Get-VSCodeNodeBin +# Code.exe doubles as node under ELECTRON_RUN_AS_NODE=1 — the same trick VS Code's +# own CLI shim uses, so the fallback writer needs no extra dependency. +function Get-VSCodeNodeBin { + param([string]$FilePath) + $root = Get-VSCodeInstallRoot $FilePath + foreach ($exe in @('Code.exe', 'Code - Insiders.exe')) { + $p = Join-Path $root $exe + if (Test-Path -LiteralPath $p -PathType Leaf) { return $p } + } + return $null +} + +# Test-VSCodeCanWrite +# Opens for write and closes immediately: no content change, no mtime change, but +# it fails exactly where a real write would (ACLs, or a file locked by a running +# VS Code). Checked up front so a permission problem is reported as such rather +# than surfacing as a half-applied patch. +function Test-VSCodeCanWrite { + param([string]$FilePath) + try { + $fs = [System.IO.File]::Open($FilePath, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) + $fs.Close() + return $true + } catch { return $false } +} + +# ── Sidecar state ───────────────────────────────────────────────────────────── +# Holds the rendered gallery URL for the watcher plus re-apply telemetry. Never +# inside the install directory. Overridable so it can be exercised off-Windows. + +function Get-VSCodeStateDir { + if ($env:ENDOR_VSCODE_STATE_DIR) { return $env:ENDOR_VSCODE_STATE_DIR } + if ($env:ProgramData) { return (Join-Path $env:ProgramData 'Endor\PackageFirewall\vscode') } + 'C:\ProgramData\Endor\PackageFirewall\vscode' +} + +function Set-VSCodeState { + param([string]$Key, [string]$Value) + $dir = Get-VSCodeStateDir + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $f = Join-Path $dir 'state' + $lines = @() + if (Test-Path -LiteralPath $f) { + $lines = @(Get-Content -LiteralPath $f | Where-Object { $_ -notmatch "^$([regex]::Escape($Key))=" }) + } + $lines += "$Key=$Value" + Write-EndorFile -FilePath $f -Lines $lines + + # The state file carries the rendered gallery URL, i.e. a live credential, so + # lock it to the account the watcher runs as. Resolved from the well-known SID + # rather than the literal string 'SYSTEM', because that account name is + # localised on non-English Windows and would fail to resolve. + if ([System.Environment]::OSVersion.Platform -eq 'Win32NT') { + try { + $sysName = (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') + ).Translate([System.Security.Principal.NTAccount]).Value + Set-FileRestrictedAcl -FilePath $f -Username $sysName + } catch { + Write-Warning "[endor-vscode] could not restrict $f -- it holds a credential; check its ACL." + } + } +} + +function Get-VSCodeState { + param([string]$Key) + $f = Join-Path (Get-VSCodeStateDir) 'state' + if (-not (Test-Path -LiteralPath $f)) { return '' } + $hit = @(Get-Content -LiteralPath $f | Where-Object { $_ -match "^$([regex]::Escape($Key))=" }) + if ($hit.Count -eq 0) { return '' } + ($hit[-1] -replace "^$([regex]::Escape($Key))=", '') +} + +# Write-VSCodeStateReport — surface watcher activity into MDM logs, so an admin can +# see that VS Code updates really are clobbering product.json on this fleet. +function Write-VSCodeStateReport { + $n = Get-VSCodeState 'repatch_count' + if ($n -and $n -ne '0') { + $last = Get-VSCodeState 'last_repatch' + if (-not $last) { $last = 'unknown' } + Write-Host "[endor-vscode] watcher has re-applied the patch ${n}x (last: $last)" + } +} + +# ── Marker + state machine ──────────────────────────────────────────────────── + +# Get-VSCodeMarkerField +# The awk/PowerShell writer emits the marker on one line; the node fallback runs it +# through JSON.stringify and pretty-prints it. Reading only the single-line shape +# would silently break restore for node-written files, so fall back to extracting +# the marker as a block. +function Get-VSCodeMarkerField { + param([string]$FilePath, [string]$Field) + $pat = '"' + [regex]::Escape($Field) + '"[ \t]*:[ \t]*"([^"]*)"' + $lines = (Get-JsonDoc $FilePath).Lines + foreach ($l in $lines) { + if ($l -match [regex]::Escape($ENDOR_JSON_MARKER_KEY)) { + $m = [regex]::Match($l, $pat) + if ($m.Success) { return $m.Groups[1].Value } + } + } + $blk = Get-JsonTopObjectBlock -Lines $lines -Key $ENDOR_JSON_MARKER_KEY + if ($blk) { + foreach ($l in $blk) { + $m = [regex]::Match($l, $pat) + if ($m.Success) { return $m.Groups[1].Value } + } + } + return '' +} + +# Get-VSCodeManagedState +# unmanaged | current | stale. A 'stale' file must be restored before being +# re-patched — never patch on top of a patch, or the captured original is lost. +function Get-VSCodeManagedState { + param([string]$FilePath, [string]$Url, [string[]]$DeleteKeys) + $raw = [System.IO.File]::ReadAllText($FilePath) + if (-not $raw.Contains('"' + $ENDOR_JSON_MARKER_KEY + '"')) { return 'unmanaged' } + if (-not $raw.Contains('"serviceUrl": "' + $Url + '"')) { return 'stale' } + foreach ($k in $DeleteKeys) { + if ($k -and $raw.Contains('"' + $k.Trim() + '"')) { return 'stale' } + } + 'current' +} + +# ── Patch / unpatch ─────────────────────────────────────────────────────────── + +function Get-VSCodeMarkerBase { + param([string]$Namespace, [string]$Fqdn, [string[]]$Lines) + $ver = Get-JsonTopString -Lines $Lines -Key 'version' + $cmt = Get-JsonTopString -Lines $Lines -Key 'commit' + if (-not $ver) { $ver = 'unknown' } + if (-not $cmt) { $cmt = 'unknown' } + $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + '{"schema":1,"namespace":"' + $Namespace + '","fqdn":"' + $Fqdn + + '","appVersion":"' + $ver + '","appCommit":"' + $cmt + '","patchedAt":"' + $ts + '"' +} + +# Invoke-VSCodePatchViaNode — fallback for a product.json that is not +# line-oriented (repackaged or minified). Reformats the whole file, which is +# acceptable precisely because the layout was already non-standard. Records +# via:"node" so unpatch restores the same way. +function Invoke-VSCodePatchViaNode { + param([string]$FilePath, [string]$NodeBin, [string]$Url, [string[]]$DeleteKeys, + [string]$MarkerBase, [string]$OutFile) + $js = @' +const fs = require("fs"); +const d = JSON.parse(fs.readFileSync(process.env.ENDOR_PJ, "utf8")); +const orig = JSON.stringify(d.extensionsGallery || {}); +const g = Object.assign({}, d.extensionsGallery || {}); +g.serviceUrl = process.env.ENDOR_URL; +(process.env.ENDOR_DEL || "").split(/\s+/).filter(Boolean).forEach(k => { delete g[k]; }); +const marker = Object.assign(JSON.parse(process.env.ENDOR_MARKER), { + via: "node", + originalExtensionsGalleryB64: Buffer.from(orig).toString("base64"), +}); +const out = {}; +out[process.env.ENDOR_MARKER_KEY] = marker; +for (const k of Object.keys(d)) out[k] = (k === "extensionsGallery") ? g : d[k]; +fs.writeFileSync(process.env.ENDOR_OUT, JSON.stringify(out, null, "\t")); +'@ + try { + $env:ENDOR_PJ = $FilePath; $env:ENDOR_URL = $Url + $env:ENDOR_DEL = ($DeleteKeys -join ' '); $env:ENDOR_OUT = $OutFile + $env:ENDOR_MARKER_KEY = $ENDOR_JSON_MARKER_KEY + $env:ENDOR_MARKER = ($MarkerBase + '}') + $env:ELECTRON_RUN_AS_NODE = '1' + & $NodeBin -e $js 2>$null | Out-Null + return (Test-Path -LiteralPath $OutFile) + } catch { return $false } finally { + foreach ($v in 'ENDOR_PJ','ENDOR_URL','ENDOR_DEL','ENDOR_OUT','ENDOR_MARKER_KEY','ENDOR_MARKER','ELECTRON_RUN_AS_NODE') { + Remove-Item "Env:\$v" -ErrorAction SilentlyContinue + } + } +} + +function Invoke-VSCodeUnpatchViaNode { + param([string]$FilePath, [string]$NodeBin, [string]$OutFile) + $js = @' +const fs = require("fs"); +const d = JSON.parse(fs.readFileSync(process.env.ENDOR_PJ, "utf8")); +const m = d[process.env.ENDOR_MARKER_KEY] || {}; +if (m.originalExtensionsGalleryB64) { + d.extensionsGallery = JSON.parse(Buffer.from(m.originalExtensionsGalleryB64, "base64").toString("utf8")); +} +delete d[process.env.ENDOR_MARKER_KEY]; +fs.writeFileSync(process.env.ENDOR_OUT, JSON.stringify(d, null, "\t")); +'@ + try { + $env:ENDOR_PJ = $FilePath; $env:ENDOR_OUT = $OutFile + $env:ENDOR_MARKER_KEY = $ENDOR_JSON_MARKER_KEY + $env:ELECTRON_RUN_AS_NODE = '1' + & $NodeBin -e $js 2>$null | Out-Null + return (Test-Path -LiteralPath $OutFile) + } catch { return $false } finally { + foreach ($v in 'ENDOR_PJ','ENDOR_OUT','ENDOR_MARKER_KEY','ELECTRON_RUN_AS_NODE') { + Remove-Item "Env:\$v" -ErrorAction SilentlyContinue + } + } +} + +# Invoke-VSCodePatch +# Returns 0 patched · 2 already current (nothing written) · 1 failed. +function Invoke-VSCodePatch { + param([string]$FilePath, [string]$Url, [string[]]$SetLines, [string[]]$DeleteKeys, + [string]$Namespace, [string]$Fqdn, [switch]$DryRun) + + $label = Get-VSCodeEditionLabel $FilePath + $state = Get-VSCodeManagedState -FilePath $FilePath -Url $Url -DeleteKeys $DeleteKeys + + if ($state -eq 'current') { + Write-Host "[endor-vscode] ok ${label}: already current -- no change" + return 2 + } + + if ($DryRun) { + Write-Host "[dry-run] action : $state -> PATCH product.json" + if ($state -eq 'stale') { + Write-Host '[dry-run] note : stale -- original restored first, then re-patched' + } + Write-Host "[dry-run] file : $FilePath" + Write-Host ("[dry-run] set : " + (Get-EndorRedactAk ($SetLines -join '; '))) + Write-Host ("[dry-run] remove : " + (($DeleteKeys -join ' '))) + Write-Host "[dry-run] marker : $ENDOR_JSON_MARKER_KEY (carries the original for restore)" + Write-Host '' + return 0 + } + + if (-not (Test-VSCodeCanWrite $FilePath)) { + Write-Warning "[endor-vscode] ${label}: cannot write $FilePath" + Write-Warning '[endor-vscode] Run as SYSTEM/Administrator. If VS Code is running, close it and re-run.' + return 1 + } + + if ($state -eq 'stale') { + Write-Host "[endor-vscode] ${label}: managed but out of date -- restoring original first" + if ((Invoke-VSCodeUnpatch -FilePath $FilePath) -ne 0) { return 1 } + } + + $nodeBin = Get-VSCodeNodeBin $FilePath + $doc = Get-JsonDoc $FilePath + $markerBase = Get-VSCodeMarkerBase -Namespace $Namespace -Fqdn $Fqdn -Lines $doc.Lines + $tmp = [System.IO.Path]::GetTempFileName() + $applied = $false + + $blk = Get-JsonTopObjectBlock -Lines $doc.Lines -Key 'extensionsGallery' + if ($blk) { + $merged = Set-JsonObjectKeys -Lines $doc.Lines -Key 'extensionsGallery' ` + -SetLines $SetLines -DeleteKeys $DeleteKeys + if ($merged) { + $origB64 = [System.Convert]::ToBase64String( + [System.Text.Encoding]::UTF8.GetBytes(($blk -join $doc.NewLine) + $doc.NewLine)) + $tind = Get-LineIndent $doc.Lines[1] + $markerLine = $tind + '"' + $ENDOR_JSON_MARKER_KEY + '": ' + $markerBase + + ',"via":"ps","originalExtensionsGalleryB64":"' + $origB64 + '"},' + $doc.Lines = Add-JsonTopLine -Lines $merged -Line $markerLine + Set-JsonDoc -Doc $doc -FilePath $tmp + $applied = $true + } + } + + if (-not $applied) { + if ($nodeBin -and (Invoke-VSCodePatchViaNode -FilePath $FilePath -NodeBin $nodeBin ` + -Url $Url -DeleteKeys $DeleteKeys -MarkerBase $markerBase -OutFile $tmp)) { + Write-Host "[endor-vscode] ${label}: product.json is not line-oriented -- used the bundled node writer" + $applied = $true + } else { + Write-Warning "[endor-vscode] ${label}: unrecognised product.json layout and no usable node binary" + Write-Warning "[endor-vscode] $FilePath was left untouched." + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + return 1 + } + } + + if (-not (Test-JsonValid $tmp)) { + Write-Warning "[endor-vscode] ${label}: patched product.json failed validation -- not installing it" + Write-Warning "[endor-vscode] $FilePath was left untouched." + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + return 1 + } + + # Write through the existing file so its ACL and identity survive. + $newDoc = Get-JsonDoc $tmp + $newDoc.HadFinalNewline = (Get-JsonDoc $FilePath).HadFinalNewline + Set-JsonDoc -Doc $newDoc -FilePath $FilePath + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + + Write-Host "[endor-vscode] ok ${label}: gallery routed through the Endor firewall" + return 0 +} + +# Invoke-VSCodeUnpatch — restores the captured original and drops the marker. +# Returns 0 on success (including "nothing to do"), 1 on failure. +function Invoke-VSCodeUnpatch { + param([string]$FilePath, [switch]$DryRun) + + $label = Get-VSCodeEditionLabel $FilePath + $raw = [System.IO.File]::ReadAllText($FilePath) + if (-not $raw.Contains('"' + $ENDOR_JSON_MARKER_KEY + '"')) { + Write-Host "[endor-remove] skip ${label}: not managed by Endor -- $FilePath" + return 0 + } + + if ($DryRun) { + Write-Host '[dry-run] action : RESTORE original extensionsGallery, drop marker' + Write-Host "[dry-run] file : $FilePath" + return 0 + } + + if (-not (Test-VSCodeCanWrite $FilePath)) { + Write-Warning "[endor-vscode] ${label}: cannot write $FilePath (privileges, or VS Code is running)" + return 1 + } + + $via = Get-VSCodeMarkerField -FilePath $FilePath -Field 'via' + $origB64 = Get-VSCodeMarkerField -FilePath $FilePath -Field 'originalExtensionsGalleryB64' + if (-not $origB64) { + Write-Warning "[endor-vscode] ${label}: marker carries no original -- refusing to guess" + Write-Warning "[endor-vscode] Reinstall ${label} to restore a pristine product.json." + return 1 + } + + $tmp = [System.IO.Path]::GetTempFileName() + $okDone = $false + + if ($via -eq 'node') { + $nodeBin = Get-VSCodeNodeBin $FilePath + if ($nodeBin -and (Invoke-VSCodeUnpatchViaNode -FilePath $FilePath -NodeBin $nodeBin -OutFile $tmp)) { + $okDone = $true + } + } else { + $doc = Get-JsonDoc $FilePath + $blockText = Get-EndorB64Decode $origB64 + $blockLines = @([System.Text.RegularExpressions.Regex]::Split($blockText.TrimEnd("`r", "`n"), "`r`n|`n")) + $restored = Set-JsonTopObjectBlock -Lines $doc.Lines -Key 'extensionsGallery' -BlockLines $blockLines + if ($restored) { + $doc.Lines = Remove-JsonTopKey -Lines $restored -Key $ENDOR_JSON_MARKER_KEY + Set-JsonDoc -Doc $doc -FilePath $tmp + $okDone = $true + } + } + + if ((-not $okDone) -or (-not (Test-JsonValid $tmp))) { + Write-Warning "[endor-vscode] ${label}: restore failed validation -- $FilePath left as-is" + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + return 1 + } + + $newDoc = Get-JsonDoc $tmp + $newDoc.HadFinalNewline = (Get-JsonDoc $FilePath).HadFinalNewline + Set-JsonDoc -Doc $newDoc -FilePath $FilePath + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + + Write-Host "[endor-remove] ok ${label}: original gallery restored" + return 0 +} + +# ── product.json re-apply watcher (Scheduled Task) ──────────────────────────── +# VS Code replaces product.json on every update -- monthly for stable, nightly for +# Insiders -- on a schedule unrelated to MDM check-in. Task Scheduler has no +# file-watch trigger, so -AtLogOn stands in for "the user updated, then relaunched" +# and the hourly repetition is the real backstop. + +function Install-VSCodeWatcher { + param([string]$ScriptPath, [switch]$DryRun) + + if ($DryRun) { + Write-Host "[dry-run] watcher: Scheduled Task ${ENDOR_VSCODE_TASK_PATH}${ENDOR_VSCODE_TASK_NAME} (startup + logon + hourly)" + Write-Host "[dry-run] repatch: $ScriptPath" + return $true + } + + if (-not (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue)) { + Write-Warning '[endor-vscode] Scheduled Task cmdlets unavailable -- cannot install the update watcher.' + Write-Warning '[endor-vscode] The patch will be lost on the next VS Code update.' + return $false + } + + try { + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$ScriptPath`"" + $triggers = @( + (New-ScheduledTaskTrigger -AtStartup), + (New-ScheduledTaskTrigger -AtLogOn) + ) + try { + $triggers += (New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Hours 1) ` + -RepetitionDuration ([TimeSpan]::MaxValue)) + } catch { + # Older Task Scheduler rejects TimeSpan.MaxValue; an indefinite + # repetition with no duration is the documented equivalent. + $triggers += (New-ScheduledTaskTrigger -Once -At (Get-Date) ` + -RepetitionInterval (New-TimeSpan -Hours 1)) + } + $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' ` + -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries -StartWhenAvailable + Register-ScheduledTask -TaskName $ENDOR_VSCODE_TASK_NAME -TaskPath $ENDOR_VSCODE_TASK_PATH ` + -Action $action -Trigger $triggers -Principal $principal -Settings $settings -Force | Out-Null + Write-Host "[endor-vscode] update watcher installed -> ${ENDOR_VSCODE_TASK_PATH}${ENDOR_VSCODE_TASK_NAME}" + return $true + } catch { + Write-Warning "[endor-vscode] could not register the update watcher: $($_.Exception.Message)" + Write-Warning '[endor-vscode] The patch will be lost on the next VS Code update.' + return $false + } +} + +function Uninstall-VSCodeWatcher { + param([switch]$DryRun) + + if ($DryRun) { + Write-Host '[dry-run] action : REMOVE update watcher (Scheduled Task) and sidecar state' + return + } + + if (Get-Command Unregister-ScheduledTask -ErrorAction SilentlyContinue) { + $existing = Get-ScheduledTask -TaskName $ENDOR_VSCODE_TASK_NAME ` + -TaskPath $ENDOR_VSCODE_TASK_PATH -ErrorAction SilentlyContinue + if ($existing) { + Unregister-ScheduledTask -TaskName $ENDOR_VSCODE_TASK_NAME ` + -TaskPath $ENDOR_VSCODE_TASK_PATH -Confirm:$false -ErrorAction SilentlyContinue + Write-Host "[endor-remove] watcher removed : ${ENDOR_VSCODE_TASK_PATH}${ENDOR_VSCODE_TASK_NAME}" + } else { + Write-Host "[endor-remove] skip (no watcher) : ${ENDOR_VSCODE_TASK_PATH}${ENDOR_VSCODE_TASK_NAME}" + } + } + + $dir = Get-VSCodeStateDir + if (Test-Path -LiteralPath $dir) { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "[endor-remove] sidecar removed : $dir" + } +} diff --git a/package-firewall/tests/README.md b/package-firewall/tests/README.md index 12bb116..8898ca8 100644 --- a/package-firewall/tests/README.md +++ b/package-firewall/tests/README.md @@ -50,6 +50,7 @@ byte-level fidelity wrong: | `bash/e2e.sh` | the generated scripts, against a sandboxed install | | `powershell/Harness.ps1` | the PowerShell harness | | `powershell/json-primitives.ps1` | mirror of `bash/json-primitives.sh`, plus CRLF fidelity | +| `powershell/lib.ps1` | mirror of `bash/lib.sh` and `bash/watcher.sh` | ## The fixture, and why not a real install @@ -72,6 +73,9 @@ present, asserting nothing version-specific. ## What is not covered +- **Windows.** Scheduled Task registration and `%ProgramFiles%` / AppData discovery + cannot run off-Windows and are reported as `skip`, never as a pass. These need a + Windows box. - **A real update.** `bash/e2e.sh` simulates one by restoring the pristine file and running the repatch script. Nothing substitutes for letting an Insiders box take a real overnight update and checking `repatch_count`. diff --git a/package-firewall/tests/powershell/Harness.ps1 b/package-firewall/tests/powershell/Harness.ps1 index cd677be..8b2faf0 100644 --- a/package-firewall/tests/powershell/Harness.ps1 +++ b/package-firewall/tests/powershell/Harness.ps1 @@ -48,3 +48,26 @@ function Get-DiffLineCount([string]$A, [string]$B) { $y = [System.IO.File]::ReadAllText($B) -split "`r`n|`n" (Compare-Object -ReferenceObject $x -DifferenceObject $y).Count } + +# Set-NameLong — copy the fixture with nameLong replaced, which is how the two +# editions are told apart. ReadAllText/WriteAllText round-trip byte-for-byte, so the +# fixture's absent final newline survives — every byte-exactness assertion +# downstream depends on that. +function Set-NameLong([string]$Src, [string]$Dst, [string]$Long) { + $t = [System.IO.File]::ReadAllText($Src) + $t = [regex]::Replace($t, '"nameLong"\s*:\s*"[^"]*"', ('"nameLong": "' + $Long + '"')) + [System.IO.File]::WriteAllText($Dst, $t, [System.Text.UTF8Encoding]::new($false)) +} + +# New-FixtureInstall — a Windows-shaped install root: \resources\app\product.json. +# The Code.exe shim stands in for the bundled Electron the fallback writer uses; +# ELECTRON_RUN_AS_NODE is simply ignored by real node. +function New-FixtureInstall([string]$Root, [string]$Long, [string]$NodeBin) { + New-Item -ItemType Directory -Path (Join-Path $Root 'resources/app') -Force | Out-Null + Set-NameLong $script:FIXTURE (Join-Path $Root 'resources/app/product.json') $Long + if ($NodeBin) { + $shim = Join-Path $Root 'Code.exe' + [System.IO.File]::WriteAllText($shim, "#!/bin/sh`nexec $NodeBin `"`$@`"`n") + if ($IsMacOS -or $IsLinux) { & chmod +x $shim } + } +} diff --git a/package-firewall/tests/powershell/lib.ps1 b/package-firewall/tests/powershell/lib.ps1 new file mode 100644 index 0000000..6569ac1 --- /dev/null +++ b/package-firewall/tests/powershell/lib.ps1 @@ -0,0 +1,196 @@ +#!/usr/bin/env pwsh +# The PowerShell VS Code lifecycle: discovery, the three-state machine, patch, +# restore, both writer paths, and the failure modes that must be loud. +# +# Two surfaces genuinely cannot run off-Windows — Scheduled Task registration and +# %ProgramFiles% / AppData discovery. They are skipped explicitly rather than quietly +# passed, because a suite that reports green on a Mac while never touching the Windows +# code path is worse than no suite at all. They still need a Windows box before this +# ships to a Windows fleet. +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Harness.ps1') +. $LIB + +$NODE = (Get-Command node -ErrorAction SilentlyContinue).Source +$T = Join-Path ([System.IO.Path]::GetTempPath()) ("psvsc-" + [guid]::NewGuid().ToString('N')) +$env:ENDOR_VSCODE_STATE_DIR = (Join-Path $T 'state') +try { + +# Windows-shaped install roots: \resources\app\product.json. +New-FixtureInstall (Join-Path $T 'Microsoft VS Code') 'Visual Studio Code' $NODE +New-FixtureInstall (Join-Path $T 'Microsoft VS Code Insiders') 'Visual Studio Code - Insiders' $NODE +$PJ = Join-Path $T 'Microsoft VS Code/resources/app/product.json' +$PJI = Join-Path $T 'Microsoft VS Code Insiders/resources/app/product.json' +$PRISTINE = Join-Path $T 'pristine.json' +Copy-Item $PJ $PRISTINE + +$URL = 'https://factory.endorlabs.com/v1/namespaces/spiderman/firewall/vscode/_ak/dGVzdHVzZXI6c2VjcmV0' +$SET = @("`"serviceUrl`": `"$URL`"") +$DEL = @('extensionUrlTemplate') +$FQDN = 'https://factory.endorlabs.com' + +Write-Host '== 1. discovery ==' +$found = Get-VSCodeInstallPath -Roots @((Join-Path $T 'Microsoft VS Code'), (Join-Path $T 'Microsoft VS Code Insiders')) +chk 'finds both editions' $found.Count 2 +chk 'edition label read from nameLong' (Get-VSCodeEditionLabel $PJI) 'Visual Studio Code - Insiders' +chk 'install root resolved' ((Get-VSCodeInstallRoot $PJ) -eq (Join-Path $T 'Microsoft VS Code')) 'True' +if ($NODE) { + # Never hardcode the executable name: it differs between stable and Insiders. + chk 'node bin resolved as Code.exe' (Split-Path -Leaf (Get-VSCodeNodeBin $PJ)) 'Code.exe' +} else { skip 'node bin (no node on PATH)' } +chk 'can write a writable file' (Test-VSCodeCanWrite $PJ) 'True' +chk 'the writability probe left the content alone' ((Get-FileHash $PJ).Hash -eq (Get-FileHash $PRISTINE).Hash) 'True' + +Write-Host '== 2. patch ==' +chk 'unmanaged before patching' (Get-VSCodeManagedState -FilePath $PJ -Url $URL -DeleteKeys $DEL) 'unmanaged' +$rc = Invoke-VSCodePatch -FilePath $PJ -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn $FQDN +chk 'patch returns 0' $rc 0 +if (Test-JsonValid $PJ) { ok 'valid JSON' } else { bad 'INVALID JSON' } +chk 'state is now current' (Get-VSCodeManagedState -FilePath $PJ -Url $URL -DeleteKeys $DEL) 'current' +chk 'marker records via=ps' (Get-VSCodeMarkerField -FilePath $PJ -Field 'via') 'ps' +chk 'marker records appVersion' (Get-VSCodeMarkerField -FilePath $PJ -Field 'appVersion') $FIXTURE_VERSION +chk 'final newline still absent, as the source had none' ((Get-JsonDoc $PJ).HadFinalNewline) 'False' +$g = (Get-Content $PJ -Raw | ConvertFrom-Json).extensionsGallery +chk 'serviceUrl set' ($g.serviceUrl -eq $URL) 'True' +chk 'extensionUrlTemplate gone (the 5xx unpkg bypass)' ($null -eq $g.extensionUrlTemplate) 'True' +chk 'controlUrl preserved' ($g.controlUrl.StartsWith('https://main.vscode-cdn.net')) 'True' +chk 'resourceUrlTemplate preserved' ($g.resourceUrlTemplate.StartsWith('https://{publisher}')) 'True' +chk 'accessSKUs preserved' $g.accessSKUs.Count $FIXTURE_SKUS + +Write-Host '== 3. idempotency ==' +$h = (Get-FileHash $PJ).Hash +$rc = Invoke-VSCodePatch -FilePath $PJ -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn $FQDN +chk 're-patch returns 2 (already current)' $rc 2 +chk 'no bytes changed' ((Get-FileHash $PJ).Hash -eq $h) 'True' + +Write-Host '== 4. credential rotation: stale -> restore, then patch ==' +# Never patch on top of a patch. Restoring first is what lets credentials rotate +# indefinitely without the captured original drifting. +$URL2 = $URL + 'rotated' +chk 'rotation detected as stale' (Get-VSCodeManagedState -FilePath $PJ -Url $URL2 -DeleteKeys $DEL) 'stale' +$rc = Invoke-VSCodePatch -FilePath $PJ -Url $URL2 -SetLines @("`"serviceUrl`": `"$URL2`"") -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn $FQDN +chk 'patch after rotation returns 0' $rc 0 +chk 'current at the new URL' (Get-VSCodeManagedState -FilePath $PJ -Url $URL2 -DeleteKeys $DEL) 'current' +chk 'exactly one marker, no accumulation' (([regex]::Matches([System.IO.File]::ReadAllText($PJ), '_endorPackageFirewall')).Count) 1 +if (Test-JsonValid $PJ) { ok 'valid JSON after rotation' } else { bad 'INVALID after rotation' } + +Write-Host '== 5. unpatch restores pristine bytes ==' +$rc = Invoke-VSCodeUnpatch -FilePath $PJ +chk 'unpatch returns 0' $rc 0 +chk 'byte-identical to pristine, after two patch cycles' ((Get-FileHash $PJ).Hash -eq (Get-FileHash $PRISTINE).Hash) 'True' +$rc = Invoke-VSCodeUnpatch -FilePath $PJ +chk 'unpatch on an unmanaged file is a no-op success' $rc 0 + +Write-Host '== 6. the node writer, on a minified product.json ==' +if ($NODE) { + $MIN = Join-Path $T 'Microsoft VS Code/resources/app/min.json' + [System.IO.File]::WriteAllText($MIN, ((Get-Content $PRISTINE -Raw | ConvertFrom-Json) | ConvertTo-Json -Depth 100 -Compress)) + chk 'range lookup returns null (the fallback trigger)' ` + ($null -eq (Get-JsonTopObjectRange -Lines (Get-JsonDoc $MIN).Lines -Key 'extensionsGallery')) 'True' + $rc = Invoke-VSCodePatch -FilePath $MIN -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn $FQDN + chk 'patch via the node writer returns 0' $rc 0 + if (Test-JsonValid $MIN) { ok 'node-written file valid' } else { bad 'node-written INVALID' } + # The node writer pretty-prints, so the marker spans several lines. This catches a + # marker reader that only handles the single-line form. + chk 'marker records via=node' (Get-VSCodeMarkerField -FilePath $MIN -Field 'via') 'node' + $gm = (Get-Content $MIN -Raw | ConvertFrom-Json).extensionsGallery + chk 'the node path applied the same two edits' (($gm.serviceUrl -eq $URL) -and ($null -eq $gm.extensionUrlTemplate)) 'True' + $rc = Invoke-VSCodeUnpatch -FilePath $MIN + chk 'node unpatch returns 0' $rc 0 + $dm = Get-Content $MIN -Raw | ConvertFrom-Json + chk 'node restore dropped the marker' ($null -eq $dm._endorPackageFirewall) 'True' + chk 'node restore reinstated extensionUrlTemplate' ($dm.extensionsGallery.extensionUrlTemplate.StartsWith('https://www.vscode-unpkg.net')) 'True' +} else { skip 'node writer fallback (no node on PATH)' } + +Write-Host '== 7. validation refuses a corrupt candidate ==' +$BAD = Join-Path $T 'bad.json'; [System.IO.File]::WriteAllText($BAD, 'not json') +chk 'rejects non-JSON' (Test-JsonValid $BAD) 'False' +$B2 = Join-Path $T 'b2.json'; [System.IO.File]::WriteAllText($B2, "{`n`t`"a`": 1,`n}") +chk 'rejects a trailing-comma object' (Test-JsonValid $B2) 'False' + +Write-Host '== 8. an unwritable file is reported, not half-applied ==' +$RO = Join-Path $T 'ro.json'; Copy-Item $PRISTINE $RO +if ($IsWindows) { + skip 'unwritable-file path (chmod is not the mechanism on Windows)' +} elseif ((& id -u) -eq '0') { + skip 'unwritable-file path (running as root, chmod cannot simulate it)' +} else { + & chmod 444 $RO + $rc = Invoke-VSCodePatch -FilePath $RO -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'ns' -Fqdn 'https://f' -WarningAction SilentlyContinue + chk 'patch fails on an unwritable file' $rc 1 + chk 'file left untouched' ((Get-FileHash $RO).Hash -eq (Get-FileHash $PRISTINE).Hash) 'True' +} + +Write-Host '== 9. dry-run writes nothing and does not print the credential ==' +$DRY = Join-Path $T 'dry.json'; Copy-Item $PRISTINE $DRY +$out = Invoke-VSCodePatch -FilePath $DRY -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn 'https://f' -DryRun 6>&1 | Out-String +chk 'no bytes written' ((Get-FileHash $DRY).Hash -eq (Get-FileHash $PRISTINE).Hash) 'True' +# The token is a bearer credential in a URL path, and MDM logs are read by more people +# than product.json is. +if ($out -match 'dGVzdHVzZXI6c2VjcmV0') { bad 'dry-run leaked the token' } +elseif ($out -match '_ak/') { ok 'token redacted in dry-run output' } +else { bad "unexpected dry-run output: $out" } + +Write-Host '== 10. sidecar telemetry ==' +# The update race cannot be closed; counting re-applies is what makes it visible in +# an MDM log rather than invisible. +Set-VSCodeState -Key 'repatch_count' -Value '4' +Set-VSCodeState -Key 'last_repatch' -Value '2026-08-04T12:00:00Z' +chk 'state round-trips' (Get-VSCodeState 'repatch_count') '4' +Set-VSCodeState -Key 'repatch_count' -Value '5' +chk 'a key is replaced, not appended' (Get-VSCodeState 'repatch_count') '5' +chk 'exactly one repatch_count line after the update' ` + (([regex]::Matches([System.IO.File]::ReadAllText((Join-Path $env:ENDOR_VSCODE_STATE_DIR 'state')), '(?m)^repatch_count=')).Count) 1 +$rep = Write-VSCodeStateReport 6>&1 | Out-String +if ($rep -match '5x' -or ((Write-VSCodeStateReport | Out-String) -match '5x')) { ok 'report surfaces the count' } +else { bad "report empty: $rep" } + +Write-Host '== 11. Windows-only surfaces ==' +if (Get-Command Register-ScheduledTask -ErrorAction SilentlyContinue) { + # On a real Windows host these are reachable, but registering a system task from a + # test would leave state behind on the box, so only the dry-run is exercised. + skip 'Scheduled Task registration (would leave a real task on this host)' + skip '%ProgramFiles% / AppData discovery (needs a real install to be meaningful)' +} else { + $r = Install-VSCodeWatcher -ScriptPath 'C:\x.ps1' -WarningAction SilentlyContinue + chk 'watcher install degrades to a warning without the cmdlets' $r 'False' + skip 'Scheduled Task registration (needs Windows)' + skip '%ProgramFiles% / AppData discovery (needs Windows)' +} +$dr = Install-VSCodeWatcher -ScriptPath 'C:\x.ps1' -DryRun +chk 'watcher dry-run reports without touching anything' $dr 'True' + +Write-Host '== 12. cross-check against the real installed product.json, if there is one ==' +# The fixture mirrors a shipped product.json but is not one. Nothing version-specific +# is asserted here, so this keeps working across VS Code updates. +$REAL = @( + '/Applications/Visual Studio Code.app/Contents/Resources/app/product.json', + "$HOME/Applications/Visual Studio Code.app/Contents/Resources/app/product.json", + "$env:ProgramFiles\Microsoft VS Code\resources\app\product.json", + "$env:LOCALAPPDATA\Programs\Microsoft VS Code\resources\app\product.json", + '/usr/share/code/resources/app/product.json' +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 +if (-not $REAL) { + skip 'real-install round-trip (no VS Code installation found)' +} else { + Write-Host " (using $REAL)" + $RJ = Join-Path $T 'real.json'; $RP = Join-Path $T 'real-pristine.json' + Copy-Item $REAL $RJ; Copy-Item $REAL $RP + $rc = Invoke-VSCodePatch -FilePath $RJ -Url $URL -SetLines $SET -DeleteKeys $DEL -Namespace 'spiderman' -Fqdn $FQDN + chk 'patch of the real file returns 0' $rc 0 + if (Test-JsonValid $RJ) { ok 'real file still valid JSON' } else { bad 'real file INVALID' } + $rg = (Get-Content $RJ -Raw | ConvertFrom-Json).extensionsGallery + chk 'real file: serviceUrl set and the unpkg fallback removed' ` + (($rg.serviceUrl -eq $URL) -and ($null -eq $rg.extensionUrlTemplate)) 'True' + # 3 lines for the two key edits, plus 1 for the inserted marker. + chk 'real file: diff is the 2 key edits plus the marker, nothing else' (Get-DiffLineCount $RP $RJ) 4 + $rc = Invoke-VSCodeUnpatch -FilePath $RJ + chk 'unpatch of the real file returns 0' $rc 0 + chk 'real file restored byte-for-byte' ((Get-FileHash $RJ).Hash -eq (Get-FileHash $RP).Hash) 'True' +} + +} finally { + if ($IsMacOS -or $IsLinux) { & chmod -R u+w $T 2>$null } + Remove-Item -Recurse -Force $T -ErrorAction SilentlyContinue +} +Summarize