@trillboards/edge-sdk 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,621 @@
1
+ <#
2
+ .SYNOPSIS
3
+ verify-update-applier.ps1 - prove apply-update.ps1 REFUSES every payload it
4
+ should refuse, and accepts the one it should.
5
+
6
+ .DESCRIPTION
7
+ An updater that installs an unverified payload is a remote-code-execution
8
+ channel into every customer's store, so the assertions that matter here are
9
+ the negative ones. EVERY negative arm is paired with a control that must
10
+ behave differently, because an assertion that would also hold if the code did
11
+ nothing proves nothing.
12
+
13
+ That is not ceremony. Three probes written for this connector in one week
14
+ passed on hardware incapable of observing the property they asserted: a window
15
+ probe on a runner with no desktop, an AllSigned control that was itself
16
+ signed, and a policy test where the policy never applied. Arm A below is the
17
+ shared control for all of them - if the happy path does not stage, every
18
+ refusal is indistinguishable from a broken harness, and this script says so
19
+ and exits non-zero rather than reporting green.
20
+
21
+ Everything is served over a real HTTP listener on 127.0.0.1, so the download,
22
+ the size check and the hash check run against actual wire bytes rather than a
23
+ file copy.
24
+
25
+ WHAT THE ARMS COVER
26
+
27
+ A happy path stages, and the marker names the right version
28
+ B wrong hash rejected; the archive is deleted
29
+ C short body rejected on size (the observable shape of a
30
+ disk that filled mid-download)
31
+ D unsigned catalog rejected
32
+ E wrong publisher rejected - and the certificate is VALID and
33
+ TRUSTED, so this proves the subject check is
34
+ what refuses it, not the status check
35
+ F signed then tampered rejected (HashMismatch)
36
+ G disk preflight rejected BEFORE any payload byte is written
37
+ H unsigned script in payload rejected
38
+ I swap over a running copy succeeds, with the existing lock control
39
+ J task-shape downgrade detected and REPAIRED; the control (-Skip
40
+ TaskShapeRestore) must leave it degraded
41
+ K unelevated caller the would-downgrade preflight refuses
42
+
43
+ WHAT THIS CANNOT PROVE. A hosted runner cannot fill its own system volume, so
44
+ arm C proves the CONSEQUENCE of a disk filling mid-download (a short body is
45
+ rejected and the partial file removed) rather than the condition itself, and
46
+ arm G proves the preflight consults real free space via Get-PSDrive and
47
+ refuses. Neither reproduces ENOSPC inside Invoke-WebRequest.
48
+
49
+ .OUTPUTS exit 0 = every arm passed; exit 1 = a failure, INCLUDING a vacuous arm.
50
+ #>
51
+ [CmdletBinding()]
52
+ param(
53
+ # Arm K needs to create a local account. Off-switch for a host that forbids it.
54
+ [switch]$SkipUnelevatedArm
55
+ )
56
+
57
+ $ErrorActionPreference = 'Stop'
58
+ Set-StrictMode -Version Latest
59
+
60
+ $Here = Split-Path -Parent $MyInvocation.MyCommand.Path # deploy\windows\ci
61
+ $Deploy = (Resolve-Path (Join-Path $Here '..')).Path # deploy\windows
62
+ $Applier = Join-Path $Deploy 'apply-update.ps1'
63
+ $TaskName = 'TrillboardsEdge'
64
+ $Root = Join-Path $env:RUNNER_TEMP 'tb-update-applier'
65
+ if (-not $env:RUNNER_TEMP) { $Root = Join-Path $env:TEMP 'tb-update-applier' }
66
+ $Www = Join-Path $Root 'www'
67
+ $Certs = New-Object System.Collections.Generic.List[string]
68
+ $LocalUser = 'tbupd'
69
+ $Failures = 0
70
+
71
+ function Say([string]$m) { Write-Host "[update-applier] $m" }
72
+ function Pass([string]$m) { Write-Host "PASS - $m" }
73
+ function Bad([string]$m) { Write-Host "FAIL - $m"; $script:Failures++ }
74
+ function Die([string]$m) { Write-Host "FAIL - $m"; Cleanup; exit 1 }
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Local HTTP origin
78
+ # ---------------------------------------------------------------------------
79
+ $Listener = $null
80
+ $ServerPs = $null
81
+ $BaseUrl = $null
82
+
83
+ function Start-Origin {
84
+ foreach ($port in 34110..34140) {
85
+ $listener = New-Object System.Net.HttpListener
86
+ $listener.Prefixes.Add("http://127.0.0.1:$port/")
87
+ try { $listener.Start() } catch { continue }
88
+ $script:Listener = $listener
89
+ $script:BaseUrl = "http://127.0.0.1:$port"
90
+ break
91
+ }
92
+ if (-not $script:Listener) { Die 'could not bind a local HTTP port for the origin' }
93
+
94
+ $script:ServerPs = [powershell]::Create()
95
+ $null = $script:ServerPs.AddScript({
96
+ param($listener, $root)
97
+ while ($listener.IsListening) {
98
+ try { $ctx = $listener.GetContext() } catch { break }
99
+ try {
100
+ $rel = [Uri]::UnescapeDataString($ctx.Request.Url.AbsolutePath).TrimStart('/')
101
+ $file = Join-Path $root $rel
102
+ if (Test-Path -LiteralPath $file) {
103
+ $bytes = [System.IO.File]::ReadAllBytes($file)
104
+ $ctx.Response.StatusCode = 200
105
+ $ctx.Response.ContentLength64 = $bytes.Length
106
+ $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length)
107
+ } else {
108
+ $ctx.Response.StatusCode = 404
109
+ }
110
+ } catch {
111
+ } finally {
112
+ try { $ctx.Response.OutputStream.Close() } catch {}
113
+ }
114
+ }
115
+ }).AddArgument($script:Listener).AddArgument($Www)
116
+ $null = $script:ServerPs.BeginInvoke()
117
+ Say "origin listening on $script:BaseUrl"
118
+ }
119
+
120
+ function Stop-Origin {
121
+ try { if ($script:Listener) { $script:Listener.Stop(); $script:Listener.Close() } } catch {}
122
+ try { if ($script:ServerPs) { $script:ServerPs.Dispose() } } catch {}
123
+ }
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Certificates
127
+ # ---------------------------------------------------------------------------
128
+ function New-HarnessCert([string]$Subject) {
129
+ $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=$Subject" `
130
+ -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
131
+ # Into Root, or the chain does not build and Status is UnknownError - which
132
+ # would make arm E fire for the wrong reason entirely.
133
+ $pub = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $cert.RawData)
134
+ $store = New-Object Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser')
135
+ $store.Open('ReadWrite'); $store.Add($pub); $store.Close()
136
+ $Certs.Add($cert.Thumbprint)
137
+ return $cert
138
+ }
139
+
140
+ function Remove-HarnessCerts {
141
+ foreach ($tp in $Certs) {
142
+ foreach ($name in @('My', 'Root')) {
143
+ try {
144
+ $store = New-Object Security.Cryptography.X509Certificates.X509Store($name, 'CurrentUser')
145
+ $store.Open('ReadWrite')
146
+ foreach ($c in @($store.Certificates | Where-Object { $_.Thumbprint -eq $tp })) { $store.Remove($c) }
147
+ $store.Close()
148
+ } catch {}
149
+ }
150
+ }
151
+ }
152
+
153
+ function Sign-File([string]$Path, $Cert) {
154
+ $r = Set-AuthenticodeSignature -FilePath $Path -Certificate $Cert -HashAlgorithm SHA256
155
+ if ($r.Status -ne 'Valid') { Die "harness could not sign $Path (got $($r.Status))" }
156
+ }
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Fixtures
160
+ # ---------------------------------------------------------------------------
161
+ <#
162
+ A tree shaped exactly like the shipped connector: bin\ with the real scripts
163
+ (so the applier's "at least 10 signed executables" floor is met by the real
164
+ file set, not a stub), a node.exe, and a cli.js that answers `version` the way
165
+ install-update.ps1 requires.
166
+ #>
167
+ function New-PayloadTree([string]$Dest, $SignCert, [switch]$LogonOnlyRegisterTask, [switch]$WithUnsignedScript) {
168
+ if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
169
+ $bin = Join-Path $Dest 'bin'
170
+ $dist = Join-Path $Dest 'lib\edge-sdk\dist'
171
+ New-Item -ItemType Directory -Force -Path $bin, $dist | Out-Null
172
+
173
+ foreach ($f in @(& (Join-Path $Here 'list-shipped-scripts.ps1') -Kind signable -AbsolutePath)) {
174
+ Copy-Item -LiteralPath $f -Destination (Join-Path $bin (Split-Path -Leaf $f)) -Force
175
+ }
176
+ if ($LogonOnlyRegisterTask) {
177
+ # A register-task.ps1 that deliberately produces the PRE-headless shape.
178
+ # This is how arm J reproduces, on an elevated runner, the downgrade a
179
+ # non-elevated field caller would suffer: the cause differs, the observable
180
+ # (the task comes back logon-only) is identical, and that observable is what
181
+ # the guard keys on.
182
+ $downgrade = @'
183
+ [CmdletBinding()]
184
+ param([Parameter(Mandatory = $true)][string]$InstallDir, [string]$Mode = "auto-sense")
185
+ $ErrorActionPreference = "Stop"
186
+ $UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
187
+ $action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR "System32\cmd.exe") -Argument "/c exit"
188
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
189
+ $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
190
+ Unregister-ScheduledTask -TaskName "TrillboardsEdge" -Confirm:$false -ErrorAction SilentlyContinue
191
+ Register-ScheduledTask -TaskName "TrillboardsEdge" -Action $action -Trigger $trigger -Principal $principal | Out-Null
192
+ '@
193
+ [System.IO.File]::WriteAllText((Join-Path $bin 'register-task.ps1'), $downgrade, (New-Object System.Text.UTF8Encoding($false)))
194
+ }
195
+ if ($WithUnsignedScript) {
196
+ [System.IO.File]::WriteAllText((Join-Path $bin 'rogue.ps1'), "exit 0`r`n", (New-Object System.Text.UTF8Encoding($false)))
197
+ }
198
+
199
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
200
+ [System.IO.File]::WriteAllText(
201
+ (Join-Path $dist 'cli.js'),
202
+ "if (process.argv[2] === 'version') { console.log('@trillboards/edge-sdk v9.9.9'); process.exit(0); }`r`nsetInterval(function(){}, 1000);`r`n",
203
+ (New-Object System.Text.UTF8Encoding($false)))
204
+
205
+ foreach ($f in @(Get-ChildItem -LiteralPath $bin -File | Where-Object { $_.Name -ne 'rogue.ps1' })) {
206
+ if ($f.Extension -in @('.ps1', '.vbs')) { Sign-File $f.FullName $SignCert }
207
+ }
208
+ # The runner's node.exe is the official OpenJS build and already Valid; sign a
209
+ # copy only if it is not, so the floor check measures signatures rather than
210
+ # the runner image.
211
+ $nodeSig = Get-AuthenticodeSignature -LiteralPath (Join-Path $bin 'node.exe')
212
+ if ($nodeSig.Status -ne 'Valid') { Sign-File (Join-Path $bin 'node.exe') $SignCert }
213
+ }
214
+
215
+ function New-InstallTree([string]$Dest) {
216
+ if (Test-Path -LiteralPath $Dest) { Remove-Item -LiteralPath $Dest -Recurse -Force }
217
+ $bin = Join-Path $Dest 'bin'
218
+ $dist = Join-Path $Dest 'lib\edge-sdk\dist'
219
+ New-Item -ItemType Directory -Force -Path $bin, $dist, (Join-Path $Dest 'logs') | Out-Null
220
+ foreach ($f in @('stop-existing.ps1', 'register-task.ps1', 'install-update.ps1', 'apply-update.ps1', 'run-connector.ps1', 'trillboards-edge-task.vbs')) {
221
+ Copy-Item -LiteralPath (Join-Path $Deploy $f) -Destination (Join-Path $bin $f) -Force
222
+ }
223
+ Copy-Item -LiteralPath (Get-Command node).Source -Destination (Join-Path $bin 'node.exe') -Force
224
+ [System.IO.File]::WriteAllText((Join-Path $dist 'cli.js'), "setInterval(function(){}, 1000);`r`n", (New-Object System.Text.UTF8Encoding($false)))
225
+ }
226
+
227
+ function New-Catalog([string]$Path, [string]$Version, [string]$Sha, [long]$Size) {
228
+ $lines = @(
229
+ '# Trillboards connector update catalog - harness fixture.',
230
+ ("`$TrillboardsUpdateVersion = '" + $Version + "'"),
231
+ ("`$TrillboardsUpdateSha256 = '" + $Sha + "'"),
232
+ ("`$TrillboardsUpdateSizeBytes = " + $Size)
233
+ )
234
+ [System.IO.File]::WriteAllText($Path, (($lines -join "`r`n") + "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
235
+ }
236
+
237
+ <#
238
+ Run the applier and report what it concluded. Returns the parsed outcome.json
239
+ plus whether staged.json exists, which together are the only observable the
240
+ connector ever acts on.
241
+ #>
242
+ function Invoke-Applier([string]$InstallDir, [string[]]$Args) {
243
+ $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
244
+ $all = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $Applier, '-InstallDir', $InstallDir) + $Args
245
+ $p = Start-Process -FilePath $ps -ArgumentList $all -NoNewWindow -PassThru -Wait
246
+ $updateDir = Join-Path $InstallDir '.auto-update'
247
+ $outcome = $null
248
+ $outcomePath = Join-Path $updateDir 'outcome.json'
249
+ if (Test-Path -LiteralPath $outcomePath) {
250
+ try { $outcome = Get-Content -LiteralPath $outcomePath -Raw | ConvertFrom-Json } catch {}
251
+ }
252
+ # Precomputed rather than inlined into the hashtable: an `if` in a hashtable
253
+ # VALUE position is a statement, and Set-StrictMode makes the property reads
254
+ # inside it throw when $outcome is absent.
255
+ $result = ''
256
+ $detail = ''
257
+ if ($null -ne $outcome) {
258
+ $result = [string]$outcome.result
259
+ if ($null -ne $outcome.detail) { $detail = [string]$outcome.detail }
260
+ }
261
+ return [pscustomobject]@{
262
+ ExitCode = $p.ExitCode
263
+ Outcome = $outcome
264
+ Result = $result
265
+ Detail = $detail
266
+ Staged = (Test-Path -LiteralPath (Join-Path $updateDir 'staged.json'))
267
+ Archive = (Test-Path -LiteralPath (Join-Path $updateDir 'payload.zip'))
268
+ }
269
+ }
270
+
271
+ function Publish-Payload([string]$TreeDir, [string]$Name) {
272
+ $zip = Join-Path $Www $Name
273
+ if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
274
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
275
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($TreeDir, $zip, [System.IO.Compression.CompressionLevel]::Fastest, $false)
276
+ return [pscustomobject]@{
277
+ Path = $zip
278
+ Bytes = (Get-Item -LiteralPath $zip).Length
279
+ Sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
280
+ }
281
+ }
282
+
283
+ function Get-Shape([string]$Name) {
284
+ $t = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
285
+ if (-not $t) { return $null }
286
+ $xml = Export-ScheduledTask -TaskName $Name
287
+ $logon = 'unknown'
288
+ $m = [regex]::Match($xml, '<LogonType>([^<]+)</LogonType>')
289
+ if ($m.Success) { $logon = $m.Groups[1].Value }
290
+ $free = ($xml -match '<BootTrigger>') -or ($xml -match '<Repetition>')
291
+ return [pscustomobject]@{ LogonType = $logon; Headless = ($free -and ($logon -ne 'InteractiveToken')) }
292
+ }
293
+
294
+ function Register-HeadlessTask([string]$InstallDir) {
295
+ $userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
296
+ $action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\cmd.exe') -Argument '/c exit'
297
+ # A repetition trigger far in the future: logon-free by shape, and it cannot
298
+ # fire during the run and start anything.
299
+ $trigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddDays(3650)) `
300
+ -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
301
+ $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType S4U -RunLevel Limited
302
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
303
+ Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal | Out-Null
304
+ }
305
+
306
+ function Cleanup {
307
+ Stop-Origin
308
+ Remove-HarnessCerts
309
+ try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
310
+ try { Unregister-ScheduledTask -TaskName 'TrillboardsEdgeUpdateProbe' -Confirm:$false -ErrorAction SilentlyContinue } catch {}
311
+ try { Remove-LocalUser -Name $LocalUser -ErrorAction SilentlyContinue } catch {}
312
+ }
313
+
314
+ # ---------------------------------------------------------------------------
315
+ try {
316
+ if (-not (Test-Path -LiteralPath $Applier)) { Die "apply-update.ps1 not found at $Applier" }
317
+ if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force }
318
+ New-Item -ItemType Directory -Force -Path $Root, $Www | Out-Null
319
+ Start-Origin
320
+
321
+ $goodCert = New-HarnessCert 'Trillboards CI Update Harness'
322
+ $otherCert = New-HarnessCert 'Definitely Not Trillboards'
323
+ $signer = 'Trillboards CI Update Harness'
324
+ $version = '9.9.9'
325
+
326
+ $payloadTree = Join-Path $Root 'payload-src'
327
+ New-PayloadTree -Dest $payloadTree -SignCert $goodCert
328
+ $good = Publish-Payload $payloadTree 'payload.zip'
329
+ Say ("payload: {0} bytes, sha {1}" -f $good.Bytes, $good.Sha)
330
+
331
+ $catalogGood = Join-Path $Www 'catalog-good.ps1'
332
+ New-Catalog $catalogGood $version $good.Sha $good.Bytes
333
+ Sign-File $catalogGood $goodCert
334
+
335
+ $urlZip = "$BaseUrl/payload.zip"
336
+
337
+ # ---- A: HAPPY PATH. The control for every arm below it -------------------
338
+ Say '--- arm A: happy path (the shared control) ---'
339
+ $installA = Join-Path $Root 'install-a'
340
+ New-InstallTree $installA
341
+ $a = Invoke-Applier $installA @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
342
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
343
+ if (-not $a.Staged) {
344
+ Die "arm A did not stage (exit $($a.ExitCode), result '$($a.Result)', detail '$($a.Detail)') - every refusal below would be VACUOUS"
345
+ }
346
+ $stagedVersion = (Get-Content -LiteralPath (Join-Path $installA '.auto-update\staged.json') -Raw | ConvertFrom-Json).version
347
+ if ($stagedVersion -ne $version) { Bad "arm A staged version '$stagedVersion', expected '$version'" }
348
+ else { Pass 'a correct, signed payload stages, and the marker names the staged version' }
349
+
350
+ # ---- B: WRONG HASH -------------------------------------------------------
351
+ Say '--- arm B: the payload does not match the signed hash ---'
352
+ $tampered = Join-Path $Www 'payload-tampered.zip'
353
+ Copy-Item -LiteralPath $good.Path -Destination $tampered -Force
354
+ $bytes = [System.IO.File]::ReadAllBytes($tampered)
355
+ $bytes[$bytes.Length - 1] = [byte](($bytes[$bytes.Length - 1] + 1) % 256)
356
+ [System.IO.File]::WriteAllBytes($tampered, $bytes)
357
+ $installB = Join-Path $Root 'install-b'
358
+ New-InstallTree $installB
359
+ $b = Invoke-Applier $installB @('-Stage', '-UpdateUrl', "$BaseUrl/payload-tampered.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
360
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
361
+ if ($b.Staged) { Bad 'arm B staged a payload whose SHA-256 did not match the signed catalog' }
362
+ elseif ($b.Result -ne 'rejected') { Bad "arm B recorded '$($b.Result)', expected 'rejected'" }
363
+ elseif ($b.Detail -notmatch 'SHA-256 mismatch') { Bad "arm B rejected for the wrong reason: $($b.Detail)" }
364
+ elseif ($b.Archive) { Bad 'arm B left the rejected archive on disk' }
365
+ else { Pass 'a payload that fails the signed hash is rejected and deleted' }
366
+
367
+ # ---- C: SHORT BODY -------------------------------------------------------
368
+ Say '--- arm C: the download is short (what a disk filling mid-download looks like) ---'
369
+ $short = Join-Path $Www 'payload-short.zip'
370
+ # A real byte[] copy, not a PowerShell range slice: `$bytes[0..n]` boxes every
371
+ # element into an Object[], which WriteAllBytes will not take and which is
372
+ # pathologically slow at payload scale.
373
+ $full = [System.IO.File]::ReadAllBytes($good.Path)
374
+ $half = New-Object byte[] ([int]($good.Bytes / 2))
375
+ [System.Array]::Copy($full, 0, $half, 0, $half.Length)
376
+ [System.IO.File]::WriteAllBytes($short, $half)
377
+ $installC = Join-Path $Root 'install-c'
378
+ New-InstallTree $installC
379
+ $c = Invoke-Applier $installC @('-Stage', '-UpdateUrl', "$BaseUrl/payload-short.zip", '-CatalogUrl', "$BaseUrl/catalog-good.ps1",
380
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
381
+ if ($c.Staged) { Bad 'arm C staged a truncated payload' }
382
+ elseif ($c.Detail -notmatch 'size mismatch') { Bad "arm C rejected for the wrong reason: $($c.Detail)" }
383
+ elseif ($c.Archive) { Bad 'arm C left the truncated archive on disk' }
384
+ else { Pass 'a short download is rejected on size and the partial file is removed' }
385
+
386
+ # ---- D: UNSIGNED CATALOG -------------------------------------------------
387
+ Say '--- arm D: the catalog is not signed at all ---'
388
+ $catalogUnsigned = Join-Path $Www 'catalog-unsigned.ps1'
389
+ New-Catalog $catalogUnsigned $version $good.Sha $good.Bytes
390
+ if ((Get-AuthenticodeSignature -LiteralPath $catalogUnsigned).Status -eq 'Valid') {
391
+ Die 'arm D is VACUOUS: the "unsigned" catalog reports a Valid signature'
392
+ }
393
+ $installD = Join-Path $Root 'install-d'
394
+ New-InstallTree $installD
395
+ $d = Invoke-Applier $installD @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-unsigned.ps1",
396
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
397
+ if ($d.Staged) { Bad 'arm D staged against an unsigned catalog' }
398
+ elseif ($d.Detail -notmatch 'not Valid') { Bad "arm D rejected for the wrong reason: $($d.Detail)" }
399
+ else { Pass 'an unsigned catalog is rejected' }
400
+
401
+ # ---- E: WRONG PUBLISHER (the sharpest control) ---------------------------
402
+ Say '--- arm E: the catalog is VALIDLY signed, by somebody else ---'
403
+ $catalogOther = Join-Path $Www 'catalog-other.ps1'
404
+ New-Catalog $catalogOther $version $good.Sha $good.Bytes
405
+ Sign-File $catalogOther $otherCert
406
+ $otherStatus = (Get-AuthenticodeSignature -LiteralPath $catalogOther).Status
407
+ if ($otherStatus -ne 'Valid') {
408
+ Die "arm E is VACUOUS: the other publisher's signature is '$otherStatus', not Valid - a status check alone would reject it and prove nothing about the subject check"
409
+ }
410
+ $installE = Join-Path $Root 'install-e'
411
+ New-InstallTree $installE
412
+ $e = Invoke-Applier $installE @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-other.ps1",
413
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
414
+ if ($e.Staged) { Bad 'arm E staged a catalog signed by an unexpected publisher' }
415
+ elseif ($e.Detail -notmatch 'unexpected publisher') { Bad "arm E rejected for the wrong reason: $($e.Detail)" }
416
+ else { Pass 'a VALID signature from the wrong publisher is rejected on the subject' }
417
+
418
+ # ---- F: SIGNED THEN TAMPERED --------------------------------------------
419
+ Say '--- arm F: the catalog was signed, then edited ---'
420
+ $catalogEdited = Join-Path $Www 'catalog-edited.ps1'
421
+ Copy-Item -LiteralPath $catalogGood -Destination $catalogEdited -Force
422
+ Add-Content -LiteralPath $catalogEdited -Value '# an edit after signing'
423
+ $editedStatus = (Get-AuthenticodeSignature -LiteralPath $catalogEdited).Status
424
+ if ($editedStatus -eq 'Valid') { Die 'arm F is VACUOUS: the edited catalog still reports Valid' }
425
+ $installF = Join-Path $Root 'install-f'
426
+ New-InstallTree $installF
427
+ $f = Invoke-Applier $installF @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-edited.ps1",
428
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
429
+ if ($f.Staged) { Bad 'arm F staged a catalog that was edited after signing' }
430
+ else { Pass "a catalog edited after signing is rejected ($editedStatus)" }
431
+
432
+ # ---- G: DISK PREFLIGHT ---------------------------------------------------
433
+ Say '--- arm G: the declared payload does not fit on the volume ---'
434
+ $huge = Join-Path $Www 'catalog-huge.ps1'
435
+ New-Catalog $huge $version $good.Sha 900000000000
436
+ Sign-File $huge $goodCert
437
+ $installG = Join-Path $Root 'install-g'
438
+ New-InstallTree $installG
439
+ $g = Invoke-Applier $installG @('-Stage', '-UpdateUrl', $urlZip, '-CatalogUrl', "$BaseUrl/catalog-huge.ps1",
440
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
441
+ if ($g.Staged) { Bad 'arm G staged a payload that cannot fit' }
442
+ elseif ($g.Detail -notmatch 'free disk space') { Bad "arm G rejected for the wrong reason: $($g.Detail)" }
443
+ elseif ($g.Archive) { Bad 'arm G downloaded before checking that the payload would fit' }
444
+ else { Pass 'the disk preflight consults real free space and refuses before downloading' }
445
+
446
+ # ---- H: UNSIGNED SCRIPT INSIDE THE PAYLOAD -------------------------------
447
+ Say '--- arm H: the payload contains an unsigned script ---'
448
+ $rogueTree = Join-Path $Root 'payload-rogue'
449
+ New-PayloadTree -Dest $rogueTree -SignCert $goodCert -WithUnsignedScript
450
+ $rogue = Publish-Payload $rogueTree 'payload-rogue.zip'
451
+ $catalogRogue = Join-Path $Www 'catalog-rogue.ps1'
452
+ New-Catalog $catalogRogue $version $rogue.Sha $rogue.Bytes
453
+ Sign-File $catalogRogue $goodCert
454
+ $installH = Join-Path $Root 'install-h'
455
+ New-InstallTree $installH
456
+ $h = Invoke-Applier $installH @('-Stage', '-UpdateUrl', "$BaseUrl/payload-rogue.zip", '-CatalogUrl', "$BaseUrl/catalog-rogue.ps1",
457
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
458
+ if ($h.Staged) { Bad 'arm H staged a payload containing an unsigned script' }
459
+ elseif ($h.Detail -notmatch 'unsigned or invalid') { Bad "arm H rejected for the wrong reason: $($h.Detail)" }
460
+ else { Pass 'a payload carrying an unsigned script is rejected' }
461
+
462
+ # ---- I + J: COMMIT over a running connector, and the shape guard ---------
463
+ # Both use the same fixture, because the shape guard only means anything on a
464
+ # commit that actually swapped something.
465
+ foreach ($arm in @(
466
+ @{ Name = 'J-control'; Skip = $true; ExpectHeadless = $false; ExpectResult = 'failed' },
467
+ @{ Name = 'J'; Skip = $false; ExpectHeadless = $true; ExpectResult = 'success' }
468
+ )) {
469
+ Say "--- arm $($arm.Name): commit over a RUNNING connector, with a payload that would re-register logon-only ---"
470
+ $installJ = Join-Path $Root "install-$($arm.Name)"
471
+ New-InstallTree $installJ
472
+ $downTree = Join-Path $Root "payload-down-$($arm.Name)"
473
+ New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
474
+ $down = Publish-Payload $downTree "payload-down-$($arm.Name).zip"
475
+ $catDown = Join-Path $Www "catalog-down-$($arm.Name).ps1"
476
+ New-Catalog $catDown $version $down.Sha $down.Bytes
477
+ Sign-File $catDown $goodCert
478
+
479
+ $staged = Invoke-Applier $installJ @('-Stage', '-UpdateUrl', "$BaseUrl/payload-down-$($arm.Name).zip",
480
+ '-CatalogUrl', "$BaseUrl/catalog-down-$($arm.Name).ps1", '-ExpectedVersion', $version,
481
+ '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
482
+ if (-not $staged.Staged) { Die "arm $($arm.Name) could not stage: $($staged.Detail)" }
483
+
484
+ Register-HeadlessTask $installJ
485
+ $before = Get-Shape $TaskName
486
+ if (-not $before.Headless) { Die "arm $($arm.Name) is VACUOUS: the fixture task is not headless to begin with" }
487
+
488
+ # A live connector holding bin\node.exe, exactly as in the field.
489
+ $node = Join-Path $installJ 'bin\node.exe'
490
+ $running = Start-Process -FilePath $node -ArgumentList (Join-Path $installJ 'lib\edge-sdk\dist\cli.js') -PassThru -WindowStyle Hidden
491
+ Start-Sleep -Seconds 3
492
+ $lockedBefore = $false
493
+ try { $s = [IO.File]::Open($node, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None); $s.Dispose() }
494
+ catch { $lockedBefore = $true }
495
+ if (-not $lockedBefore) {
496
+ Die "arm $($arm.Name) is VACUOUS: node.exe was not locked while the connector was running, so a successful swap proves nothing"
497
+ }
498
+
499
+ $args = @('-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
500
+ if ($arm.Skip) { $args += '-SkipTaskShapeRestore' }
501
+ $j = Invoke-Applier $installJ $args
502
+ try { Stop-Process -Id $running.Id -Force -ErrorAction SilentlyContinue } catch {}
503
+
504
+ $after = Get-Shape $TaskName
505
+ $isHeadless = ($null -ne $after) -and $after.Headless
506
+ $afterLogon = 'absent'
507
+ if ($null -ne $after) { $afterLogon = $after.LogonType }
508
+ if ($isHeadless -ne $arm.ExpectHeadless) {
509
+ Bad "arm $($arm.Name): task headless=$isHeadless, expected $($arm.ExpectHeadless) (logonType $afterLogon)"
510
+ } elseif ($j.Result -ne $arm.ExpectResult) {
511
+ Bad "arm $($arm.Name): recorded '$($j.Result)', expected '$($arm.ExpectResult)' (detail '$($j.Detail)')"
512
+ } elseif (-not $arm.Skip -and -not (Test-Path -LiteralPath (Join-Path $installJ 'lib\edge-sdk\dist\cli.js'))) {
513
+ Bad "arm $($arm.Name): the swap did not land the new lib tree"
514
+ } else {
515
+ if ($arm.Skip) { Pass 'CONTROL: with the shape guard disabled the task IS left logon-only, and that is recorded as failed' }
516
+ else { Pass 'the swap lands over a running connector, and a task left logon-only is detected and repaired' }
517
+ }
518
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
519
+ }
520
+
521
+ # ---- K: the would-downgrade preflight, from an UNELEVATED caller ---------
522
+ if ($SkipUnelevatedArm) {
523
+ Say '--- arm K SKIPPED by request ---'
524
+ } else {
525
+ Say '--- arm K: an unelevated caller must REFUSE rather than downgrade ---'
526
+ $installK = Join-Path $Root 'install-k'
527
+ New-InstallTree $installK
528
+ $downTree = Join-Path $Root 'payload-k'
529
+ New-PayloadTree -Dest $downTree -SignCert $goodCert -LogonOnlyRegisterTask
530
+ $down = Publish-Payload $downTree 'payload-k.zip'
531
+ $catK = Join-Path $Www 'catalog-k.ps1'
532
+ New-Catalog $catK $version $down.Sha $down.Bytes
533
+ Sign-File $catK $goodCert
534
+ $stagedK = Invoke-Applier $installK @('-Stage', '-UpdateUrl', "$BaseUrl/payload-k.zip", '-CatalogUrl', "$BaseUrl/catalog-k.ps1",
535
+ '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8', '-ExpectedSigner', $signer)
536
+ if (-not $stagedK.Staged) { Die "arm K could not stage: $($stagedK.Detail)" }
537
+ Register-HeadlessTask $installK
538
+ $beforeK = Get-Shape $TaskName
539
+ if (($null -eq $beforeK) -or (-not $beforeK.Headless)) {
540
+ Die 'arm K is VACUOUS: the fixture task is not headless'
541
+ }
542
+
543
+ # The technique is lifted from e2e/test-headless-principal.ps1, which
544
+ # measured it working on this runner image (run 31836760844): a STANDARD
545
+ # local account, `Start-Process -Credential -LoadUserProfile`, and icacls
546
+ # rather than Set-Acl. Each of those details is load-bearing -- without
547
+ # -LoadUserProfile the child has no %USERPROFILE%, and without a
548
+ # pre-materialised profile the first run measures profile creation rather
549
+ # than the thing under test.
550
+ $ok = $true
551
+ try {
552
+ $plain = 'Tb!' + [guid]::NewGuid().ToString('N').Substring(0, 16)
553
+ $pw = ConvertTo-SecureString $plain -AsPlainText -Force
554
+ Get-LocalUser -Name $LocalUser -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue
555
+ New-LocalUser -Name $LocalUser -Password $pw -AccountNeverExpires -PasswordNeverExpires `
556
+ -UserMayNotChangePassword -Description 'Trillboards update-applier probe' -ErrorAction Stop | Out-Null
557
+ # Users ONLY, never Administrators -- an admin account can create the S4U
558
+ # shape and this arm would assert nothing.
559
+ Add-LocalGroupMember -Group (Get-LocalGroup -SID 'S-1-5-32-545').Name -Member $LocalUser -ErrorAction SilentlyContinue
560
+ & icacls.exe $installK /grant '*S-1-1-0:(OI)(CI)M' /T | Out-Null
561
+
562
+ $cred = New-Object System.Management.Automation.PSCredential($LocalUser, $pw)
563
+ $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
564
+ # Materialise the profile once, so the run under test is not measuring
565
+ # first-logon profile creation.
566
+ Start-Process -FilePath $env:ComSpec -ArgumentList '/c', 'exit' -Credential $cred -LoadUserProfile `
567
+ -WorkingDirectory 'C:\' -Wait -ErrorAction Stop
568
+
569
+ # Drive the COPY inside the install tree, not the one in the repo
570
+ # workspace: the ACL above covers the install tree, and the runner's
571
+ # workspace is not necessarily readable by a fresh local account.
572
+ $applierCopy = Join-Path $installK 'bin\apply-update.ps1'
573
+ $outK = Join-Path $Root 'k-stdout.txt'
574
+ $errK = Join-Path $Root 'k-stderr.txt'
575
+ $null = Start-Process -FilePath $ps -Credential $cred -LoadUserProfile -WorkingDirectory 'C:\' -PassThru -Wait `
576
+ -RedirectStandardOutput $outK -RedirectStandardError $errK `
577
+ -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $applierCopy,
578
+ '-InstallDir', $installK, '-Commit', '-ExpectedVersion', $version, '-CurrentVersion', '9.9.8')
579
+ foreach ($f in @($outK, $errK)) {
580
+ if (Test-Path -LiteralPath $f) {
581
+ $t = Get-Content -Raw -LiteralPath $f
582
+ if ($t -and $t.Trim()) { Say " [arm K $(Split-Path -Leaf $f)] $($t.Trim())" }
583
+ }
584
+ }
585
+ } catch {
586
+ $ok = $false
587
+ Say "arm K could not run as a standard user on this host: $($_.Exception.Message)"
588
+ Write-Host 'UNPROVEN - arm K: the unelevated caller could not be created, so the would-downgrade refusal is NOT observed on this host'
589
+ $script:Failures++
590
+ }
591
+ if ($ok) {
592
+ $after = Get-Shape $TaskName
593
+ $k = $null
594
+ $op = Join-Path $installK '.auto-update\outcome.json'
595
+ if (Test-Path -LiteralPath $op) { try { $k = Get-Content -LiteralPath $op -Raw | ConvertFrom-Json } catch {} }
596
+ $result = if ($k) { [string]$k.result } else { '' }
597
+ $detail = if ($k -and $k.detail) { [string]$k.detail } else { '' }
598
+ if ($null -ne $after -and $after.Headless -and $result -eq 'rejected' -and $detail -match 'logon-only') {
599
+ Pass 'an unelevated caller REFUSES the update rather than downgrading a headless machine'
600
+ } elseif ($null -ne $after -and $after.Headless -and $result -eq 'success') {
601
+ # The account this ran as could still register the shape. Say so plainly
602
+ # rather than claiming a refusal that did not happen.
603
+ Write-Host 'UNPROVEN - arm K: the standard user was able to re-create the headless shape, so the refusal branch did not fire'
604
+ $script:Failures++
605
+ } else {
606
+ Bad "arm K: headless=$(if ($after) { $after.Headless } else { 'absent' }), result '$result', detail '$detail'"
607
+ }
608
+ }
609
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
610
+ }
611
+
612
+ Cleanup
613
+ if ($Failures -gt 0) { Write-Host "$Failures arm(s) failed"; exit 1 }
614
+ Write-Host 'All update-applier arms passed, every negative arm against a control that fired.'
615
+ exit 0
616
+ } catch {
617
+ Write-Host "FAIL - harness error: $($_.Exception.Message)"
618
+ Write-Host $_.ScriptStackTrace
619
+ Cleanup
620
+ exit 1
621
+ }
@@ -369,6 +369,9 @@ export declare class CloudSensingManager extends EventEmitter {
369
369
  name: string;
370
370
  captureCount: number;
371
371
  postCount: number;
372
+ /** Of those posts, how many the server REJECTED. `postCount - postRejected`
373
+ * is the only number that means "observations that were actually stored". */
374
+ postRejected: number;
372
375
  faceDetected: boolean;
373
376
  currentIntervalMs: number;
374
377
  admissionDeniedCount: number;
@@ -485,6 +488,13 @@ export declare class CloudSensingManager extends EventEmitter {
485
488
  analyses: number;
486
489
  admissionDenied: number;
487
490
  motionSkipped: number;
491
+ /**
492
+ * Uploads the SERVER rejected this run. Present because a rejected feed is
493
+ * otherwise indistinguishable from a quiet one: `analyses` simply stops
494
+ * rising, and the `unavailable` beacon that would say so travels over the
495
+ * same rejected credential.
496
+ */
497
+ rejected: number;
488
498
  /** This feed's skips, attributed to the cascade stage that made them. */
489
499
  motionSkippedByReason: Record<MotionGateReason, number>;
490
500
  /** Candidates this feed is holding right now — "camera 12 is waiting". */