@trillboards/edge-sdk 0.2.16 → 0.2.18

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.
Files changed (36) hide show
  1. package/THIRD-PARTY-NOTICES.md +51 -0
  2. package/deploy/macos/build-macos-pkg.sh +35 -5
  3. package/deploy/macos/scripts/setup-litertlm.sh +35 -10
  4. package/deploy/windows/README.md +17 -4
  5. package/deploy/windows/build-windows.ps1 +25 -2
  6. package/deploy/windows/ci/list-shipped-scripts.ps1 +58 -0
  7. package/deploy/windows/ci/tb-canary.ps1 +18 -0
  8. package/deploy/windows/ci/tb-canary.vbs +17 -0
  9. package/deploy/windows/ci/verify-allsigned-enforcement.ps1 +608 -0
  10. package/deploy/windows/e2e/test-headless-principal.ps1 +632 -0
  11. package/deploy/windows/ensure-sensing.ps1 +91 -1
  12. package/deploy/windows/register-litertlm-task.ps1 +114 -15
  13. package/deploy/windows/register-task.ps1 +162 -12
  14. package/deploy/windows/setup-litertlm.ps1 +78 -32
  15. package/deploy/windows/stop-existing.ps1 +2 -2
  16. package/deploy/windows/trillboards-hidden-run.vbs +3 -3
  17. package/deploy/windows/trillboards-setup.ps1 +2 -2
  18. package/dist/CloudSensingManager.d.ts +13 -4
  19. package/dist/CloudSensingManager.d.ts.map +1 -1
  20. package/dist/CloudSensingManager.js +6 -0
  21. package/dist/CloudSensingManager.js.map +1 -1
  22. package/dist/EdgeAgent.js +1 -1
  23. package/dist/LiteRtVlmProvider.d.ts +108 -45
  24. package/dist/LiteRtVlmProvider.d.ts.map +1 -1
  25. package/dist/LiteRtVlmProvider.js +124 -84
  26. package/dist/LiteRtVlmProvider.js.map +1 -1
  27. package/dist/diagnose.d.ts +1 -1
  28. package/dist/diagnose.js +1 -1
  29. package/dist/localSenseE2eHarness.d.ts +17 -4
  30. package/dist/localSenseE2eHarness.d.ts.map +1 -1
  31. package/dist/localSenseE2eHarness.js +11 -1
  32. package/dist/localSenseE2eHarness.js.map +1 -1
  33. package/dist/sensingRunMode.d.ts.map +1 -1
  34. package/dist/sensingRunMode.js +49 -31
  35. package/dist/sensingRunMode.js.map +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,608 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Prove, on a real Windows host, that the shipped scripts survive a machine-wide
4
+ `AllSigned` policy - and prove the assertion FAILS against unsigned scripts
5
+ first, so a green result cannot mean "the policy never applied".
6
+
7
+ .DESCRIPTION
8
+ The product's runtime invocation sites all pass `-ExecutionPolicy Bypass`.
9
+ That sets only the *Process* scope, and PowerShell resolves policy
10
+ MachinePolicy > UserPolicy > Process > CurrentUser > LocalMachine - so a GPO
11
+ of `AllSigned` (routine on a managed corporate estate) overrides every one of
12
+ them. This script sets that GPO for real (HKLM\SOFTWARE\Policies\...) and
13
+ measures what happens.
14
+
15
+ It is built the way the desktop-less window probe should have been built (see
16
+ .claude/lessons/2026-08-14-a-hosted-windows-runner-has-no-desktop-...md): every
17
+ positive assertion is paired with a control that MUST fire. Four controls:
18
+
19
+ A unsigned -> must be BLOCKED (policy is really in force)
20
+ B signed, publisher untrusted -> must be BLOCKED (we measure trust, not bytes)
21
+ C signed + publisher trusted -> must be ALLOWED <- the deliverable
22
+ D signed then tampered -> must be BLOCKED (we measure THIS signature)
23
+
24
+ If A, B or D does not fire, the harness reports VACUOUS and exits non-zero.
25
+ A pass therefore cannot be produced by a policy that failed to apply, by a
26
+ cert store that trusts everything, or by a gate that ignores the signature.
27
+
28
+ Shipped scripts are probed WITHOUT executing them. Their bodies stop scheduled
29
+ tasks and move install trees, so the probe invokes each one with an unknown
30
+ named parameter: PowerShell checks the execution policy when it LOADS the file,
31
+ before it binds parameters, so a [CmdletBinding()] script gives up the gate's
32
+ verdict and then dies on a binding error with its body never entered. Two of
33
+ the shipped scripts do not declare [CmdletBinding()] - an unknown argument
34
+ would fall through to positional binding and RUN them - so those assert the two
35
+ inputs to the same decision instead (signature Valid, publisher trusted).
36
+ Which route each file took is printed, never inferred.
37
+
38
+ NB `(Get-Command <path> -CommandType ExternalScript).ScriptBlock` looks like it
39
+ should work and does NOT: command discovery does not run the policy check, and
40
+ it reports 'allowed' for a file the policy really blocks. Measured, not
41
+ assumed - the probe validates itself against the unsigned canary before it is
42
+ trusted on anything.
43
+
44
+ Execution itself is proven on canaries carrying the IDENTICAL signature
45
+ provenance (in -AlreadySigned mode the canaries are signed by the production
46
+ certificate in the same signing call as the shipped files).
47
+
48
+ .PARAMETER ScriptPath Shipped .ps1 files to prove.
49
+ .PARAMETER VbsPath Shipped .vbs files to prove.
50
+ .PARAMETER CmdPath Shipped .cmd files. Recorded, not gated - see below.
51
+ .PARAMETER CanaryPs1 An already-signed .ps1 that is safe to EXECUTE.
52
+ .PARAMETER CanaryVbs An already-signed .vbs that is safe to EXECUTE.
53
+ .PARAMETER SelfSign Hermetic mode: mint a throwaway code-signing cert and
54
+ sign COPIES of the inputs. Used by the per-PR lane,
55
+ where no Azure Trusted Signing credential exists.
56
+ .PARAMETER AlreadySigned Release mode: the inputs are already Authenticode
57
+ signed (by Azure Trusted Signing) and are proved
58
+ as-is, with the unsigned control derived by stripping
59
+ the signature block from a copy.
60
+
61
+ .NOTES
62
+ .cmd/.bat have no Authenticode SIP and cmd.exe has no execution policy, so a
63
+ batch file can be neither signed nor blocked by AllSigned. This harness proves
64
+ that rather than assuming it, so the claim in the runbook stays measured.
65
+ #>
66
+ [CmdletBinding()]
67
+ param(
68
+ [string[]]$ScriptPath = @(),
69
+ [string[]]$VbsPath = @(),
70
+ [string[]]$CmdPath = @(),
71
+ [string]$CanaryPs1 = "",
72
+ [string]$CanaryVbs = "",
73
+ [switch]$SelfSign,
74
+ [switch]$AlreadySigned
75
+ )
76
+
77
+ $ErrorActionPreference = 'Stop'
78
+ Set-StrictMode -Version Latest
79
+
80
+ if (-not ($SelfSign -xor $AlreadySigned)) {
81
+ Write-Error 'Choose exactly one of -SelfSign or -AlreadySigned.'
82
+ exit 2
83
+ }
84
+
85
+ $PolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell'
86
+ $WshKey = 'HKLM:\SOFTWARE\Microsoft\Windows Script Host\Settings'
87
+ $Work = Join-Path ([IO.Path]::GetTempPath()) ("tb-allsigned-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
88
+ $failures = New-Object System.Collections.Generic.List[string]
89
+ $addedThumbprints = New-Object System.Collections.Generic.List[string]
90
+
91
+ function Say($m) { Write-Host "[allsigned] $m" }
92
+ function Fail($m) { $script:failures.Add($m); Write-Host "[allsigned] FAIL: $m" -ForegroundColor Red }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Signature-block handling. Both PowerShell and WSH append the Authenticode
96
+ # blob as trailing COMMENT lines, so an unsigned twin is the file up to the
97
+ # begin marker. Byte-exact on the retained prefix, which is what lets the
98
+ # "content unchanged by signing" assertion below be meaningful.
99
+ # ---------------------------------------------------------------------------
100
+ # The comment prefix differs per SIP - PowerShell writes
101
+ # `# SIG # Begin signature block` and WSH writes `'' SIG '' Begin signature
102
+ # block` - so cut on the part they SHARE and walk back to the start of that
103
+ # line. Hardcoding either prefix silently fails to strip the other, and a
104
+ # "unsigned" control that is still signed does not fire, which is the one
105
+ # outcome this harness must never produce.
106
+ function Copy-WithoutSignature([string]$Source, [string]$Destination) {
107
+ $text = [IO.File]::ReadAllText($Source)
108
+ $idx = $text.IndexOf('Begin signature block', [StringComparison]::Ordinal)
109
+ $found = ($idx -ge 0)
110
+ if ($found) {
111
+ $lineStart = $text.LastIndexOf("`n", $idx)
112
+ $cut = if ($lineStart -ge 0) { $lineStart + 1 } else { 0 }
113
+ $text = $text.Substring(0, $cut)
114
+ }
115
+ [IO.File]::WriteAllText($Destination, $text, (New-Object System.Text.UTF8Encoding($false)))
116
+ return $found
117
+ }
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # The gate probes.
121
+ # ---------------------------------------------------------------------------
122
+
123
+ # PS 5.1 turns any stderr from a NATIVE command into an ErrorRecord, and with
124
+ # $ErrorActionPreference = 'Stop' that becomes terminating. A blocked script
125
+ # prints "cannot be loaded" to stderr, so the harness would die the moment a
126
+ # control fired correctly - the probes must run with Continue.
127
+ function Invoke-Native([string]$Exe, [string[]]$Arguments) {
128
+ $prev = $ErrorActionPreference
129
+ $ErrorActionPreference = 'Continue'
130
+ try { & $Exe @Arguments *> $null } finally { $ErrorActionPreference = $prev }
131
+ }
132
+
133
+ # Actually EXECUTES a canary and reports whether it got to run. The canary
134
+ # writes a marker; the marker, not the exit code, is the evidence.
135
+ function Test-ScriptExecution([string]$Path) {
136
+ $marker = Join-Path $Work ("ran-" + [Guid]::NewGuid().ToString('N').Substring(0, 6) + ".txt")
137
+ $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
138
+ Invoke-Native $ps @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $Path, '-MarkerPath', $marker)
139
+ if (Test-Path -LiteralPath $marker) { return 'allowed' }
140
+ return 'blocked'
141
+ }
142
+
143
+ function Test-VbsExecution([string]$Path) {
144
+ $marker = Join-Path $Work ("vbs-" + [Guid]::NewGuid().ToString('N').Substring(0, 6) + ".txt")
145
+ $cscript = Join-Path $env:SystemRoot 'System32\cscript.exe'
146
+ Invoke-Native $cscript @('//nologo', '//B', $Path, $marker)
147
+ if (Test-Path -LiteralPath $marker) { return 'allowed' }
148
+ return 'blocked'
149
+ }
150
+
151
+ function Test-CmdExecution([string]$Path) {
152
+ $marker = Join-Path $Work ("cmd-" + [Guid]::NewGuid().ToString('N').Substring(0, 6) + ".txt")
153
+ Invoke-Native $env:ComSpec @('/c', $Path, $marker)
154
+ if (Test-Path -LiteralPath $marker) { return 'allowed' }
155
+ return 'blocked'
156
+ }
157
+
158
+ # A DIRECT policy probe for scripts that declare [CmdletBinding()]: an unknown
159
+ # named parameter is a terminating binding error, and PowerShell checks the
160
+ # execution policy when it LOADS the file, before it binds. So the gate's verdict
161
+ # is observable without the body ever running.
162
+ #
163
+ # Restricted to [CmdletBinding()] scripts on purpose. Without it, an unrecognised
164
+ # named argument falls through to positional binding or $args and the script
165
+ # RUNS - and two of the shipped scripts register scheduled tasks.
166
+ function Test-ScriptPolicyByBinding([string]$Path) {
167
+ $probe = @'
168
+ $out = $args[1]
169
+ try {
170
+ & $args[0] -TbAllSignedProbeUnbindable
171
+ [IO.File]::WriteAllText($out, 'allowed')
172
+ } catch {
173
+ if (($_.Exception -is [System.Management.Automation.PSSecurityException]) -or
174
+ ($_.FullyQualifiedErrorId -like 'UnauthorizedAccess*')) {
175
+ [IO.File]::WriteAllText($out, 'blocked')
176
+ } else {
177
+ [IO.File]::WriteAllText($out, 'allowed')
178
+ }
179
+ }
180
+ '@
181
+ $id = [Guid]::NewGuid().ToString('N').Substring(0, 8)
182
+ $probeFile = Join-Path $Work "bind-$id.txt"
183
+ $resultFile = Join-Path $Work "bindverdict-$id.txt"
184
+ [IO.File]::WriteAllText($probeFile, $probe, (New-Object System.Text.UTF8Encoding($false)))
185
+ $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
186
+ $inline = '& ([scriptblock]::Create([IO.File]::ReadAllText(''' + $probeFile + '''))) ''' + $Path + ''' ''' + $resultFile + ''''
187
+ Invoke-Native $ps @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', $inline)
188
+ if (-not (Test-Path -LiteralPath $resultFile)) { return 'indeterminate(no-verdict)' }
189
+ return ([IO.File]::ReadAllText($resultFile)).Trim()
190
+ }
191
+
192
+ function Test-DeclaresCmdletBinding([string]$Path) {
193
+ return ([IO.File]::ReadAllText($Path)) -match '(?m)^\s*\[CmdletBinding\(\)\]'
194
+ }
195
+
196
+ # StrictMode makes a missing property a terminating error, so registry values
197
+ # that may not exist have to be read through the property bag.
198
+ function Get-RegValue([string]$Key, [string]$Name) {
199
+ if (-not (Test-Path $Key)) { return $null }
200
+ $item = Get-ItemProperty -Path $Key -ErrorAction SilentlyContinue
201
+ if (-not $item) { return $null }
202
+ if ($item.PSObject.Properties.Name -notcontains $Name) { return $null }
203
+ return $item.$Name
204
+ }
205
+
206
+ function Add-CertToStore([Security.Cryptography.X509Certificates.X509Certificate2]$Cert, [string]$StoreName) {
207
+ # The PUBLIC certificate only. Nothing here reads, prints or persists a
208
+ # private key, a password or a token - the signing material never leaves the
209
+ # Trusted Signing service.
210
+ $store = New-Object Security.Cryptography.X509Certificates.X509Store($StoreName, 'LocalMachine')
211
+ $store.Open('ReadWrite')
212
+ $store.Add($Cert)
213
+ $store.Close()
214
+ }
215
+
216
+ function Remove-CertFromStore([string]$Thumbprint, [string]$StoreName) {
217
+ $store = New-Object Security.Cryptography.X509Certificates.X509Store($StoreName, 'LocalMachine')
218
+ $store.Open('ReadWrite')
219
+ foreach ($c in @($store.Certificates | Where-Object { $_.Thumbprint -eq $Thumbprint })) { $store.Remove($c) }
220
+ $store.Close()
221
+ }
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Set up
225
+ # ---------------------------------------------------------------------------
226
+ New-Item -ItemType Directory -Force -Path $Work | Out-Null
227
+
228
+ $priorPolicy = $null
229
+ $priorEnableScripts = $null
230
+ $hadPolicyKey = Test-Path $PolicyKey
231
+ $priorTrustPolicy = $null
232
+ $priorUseWinSafer = $null
233
+
234
+ try {
235
+ # --- canaries -------------------------------------------------------------
236
+ # Committed next to this harness so both modes prove the same bytes. In
237
+ # -AlreadySigned mode the release workflow has already signed them in the same
238
+ # Trusted Signing call as the shipped scripts; in -SelfSign mode they are
239
+ # signed below with the throwaway certificate.
240
+ if (-not $CanaryPs1) { $CanaryPs1 = Join-Path $PSScriptRoot 'tb-canary.ps1' }
241
+ if (-not $CanaryVbs) { $CanaryVbs = Join-Path $PSScriptRoot 'tb-canary.vbs' }
242
+ if (-not (Test-Path -LiteralPath $CanaryPs1)) { Write-Error "canary not found: $CanaryPs1"; exit 2 }
243
+ if (-not (Test-Path -LiteralPath $CanaryVbs)) { Write-Error "canary not found: $CanaryVbs"; exit 2 }
244
+ $canaryPs1Src = Join-Path $Work 'tb-canary.ps1'
245
+ $canaryVbsSrc = Join-Path $Work 'tb-canary.vbs'
246
+ Copy-Item -LiteralPath $CanaryPs1 -Destination $canaryPs1Src -Force
247
+ Copy-Item -LiteralPath $CanaryVbs -Destination $canaryVbsSrc -Force
248
+
249
+ # --- assemble the signed / unsigned pairs ---------------------------------
250
+ $signedDir = Join-Path $Work 'signed'
251
+ $unsignedDir = Join-Path $Work 'unsigned'
252
+ New-Item -ItemType Directory -Force -Path $signedDir, $unsignedDir | Out-Null
253
+
254
+ $subjects = @()
255
+ foreach ($p in @($ScriptPath + $VbsPath)) {
256
+ if (-not (Test-Path -LiteralPath $p)) { throw "input not found: $p" }
257
+ $name = Split-Path -Leaf $p
258
+ $signed = Join-Path $signedDir $name
259
+ $unsigned = Join-Path $unsignedDir $name
260
+ Copy-Item -LiteralPath $p -Destination $signed -Force
261
+ $stripped = Copy-WithoutSignature -Source $p -Destination $unsigned
262
+ # In -AlreadySigned mode the input MUST carry a block to strip. If it does
263
+ # not, the "unsigned" control is not unsigned and would not fire.
264
+ if ($AlreadySigned -and -not $stripped) { Fail "$name : no signature block found to strip - the unsigned control would be vacuous" }
265
+ $subjects += [pscustomobject]@{
266
+ Name = $name; Signed = $signed; Unsigned = $unsigned
267
+ Kind = ([IO.Path]::GetExtension($p).TrimStart('.').ToLower())
268
+ # Only [CmdletBinding()] scripts can be probed without risking execution.
269
+ Probeable = (([IO.Path]::GetExtension($p) -ieq '.ps1') -and (Test-DeclaresCmdletBinding $unsigned))
270
+ }
271
+ }
272
+ foreach ($c in @(@{ n = 'tb-canary.ps1'; s = $canaryPs1Src }, @{ n = 'tb-canary.vbs'; s = $canaryVbsSrc })) {
273
+ $signed = Join-Path $signedDir $c.n
274
+ $unsigned = Join-Path $unsignedDir $c.n
275
+ Copy-Item -LiteralPath $c.s -Destination $signed -Force
276
+ $stripped = Copy-WithoutSignature -Source $c.s -Destination $unsigned
277
+ if ($AlreadySigned -and -not $stripped) { Fail "$($c.n): no signature block found to strip - the unsigned control would be vacuous" }
278
+ }
279
+
280
+ # --- sign (hermetic mode only) -------------------------------------------
281
+ if ($SelfSign) {
282
+ Say 'Minting a throwaway code-signing certificate (hermetic mode).'
283
+ $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject 'CN=Trillboards CI AllSigned Harness' `
284
+ -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddDays(2)
285
+ $pub = New-Object Security.Cryptography.X509Certificates.X509Certificate2(, $cert.RawData)
286
+ # A self-signed cert must be in Root for the chain to build at all; without
287
+ # this its Status is UnknownError and control B would fire for the wrong
288
+ # reason.
289
+ Add-CertToStore -Cert $pub -StoreName 'Root'
290
+ $addedThumbprints.Add('Root|' + $pub.Thumbprint)
291
+ foreach ($f in @(Get-ChildItem -LiteralPath $signedDir -File)) {
292
+ $r = Set-AuthenticodeSignature -FilePath $f.FullName -Certificate $cert -HashAlgorithm SHA256
293
+ if ($r.Status -ne 'Valid') { throw "self-signing produced $($r.Status) for $($f.Name)" }
294
+ }
295
+ }
296
+
297
+ # --- what signing did to the file, measured ------------------------------
298
+ # The signer does NOT merely append: measured on windows-latest,
299
+ # Set-AuthenticodeSignature rewrites the whole file, and PS 5.1 re-emits it
300
+ # with a UTF-8 BOM and CRLF line endings. That is fine - it happens before
301
+ # packaging - but it is worth knowing, because it is the same mechanism that
302
+ # makes a post-signing edit so dangerous: whoever rewrites the file last wins,
303
+ # and the signature dies silently.
304
+ #
305
+ # So the assertion here is on the TEXT, normalised for the signer's own
306
+ # re-encoding. A genuine content change (mojibake from a codepage round-trip,
307
+ # a truncated line) still fails. The exact byte-identity claim belongs to the
308
+ # release lane, which compares the SHIPPED file to the SIGNED file - both
309
+ # post-signing, so no normalisation is needed or allowed there.
310
+ function Get-NormalisedText([string]$Path) {
311
+ $t = [IO.File]::ReadAllText($Path)
312
+ if ($t.Length -gt 0 -and $t[0] -eq [char]0xFEFF) { $t = $t.Substring(1) }
313
+ return ($t -replace "`r`n", "`n") -replace "`r", "`n"
314
+ }
315
+ foreach ($s in $subjects) {
316
+ $originalNoSig = Join-Path $Work ('orig-' + $s.Name)
317
+ $null = Copy-WithoutSignature -Source $s.Signed -Destination $originalNoSig
318
+ # TrimEnd: the retained prefix stops at the start of the signature block's
319
+ # line, so it carries one newline the original does not. That is the block's
320
+ # separator, not script content.
321
+ $before = (Get-NormalisedText $s.Unsigned).TrimEnd()
322
+ $after = (Get-NormalisedText $originalNoSig).TrimEnd()
323
+ if ($before -ne $after) {
324
+ Fail "$($s.Name): signing changed the script TEXT, not just encoding and the appended block"
325
+ }
326
+ $exact = ((Get-FileHash -Algorithm SHA256 -LiteralPath $s.Unsigned).Hash -eq
327
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $originalNoSig).Hash)
328
+ if (-not $exact) { Say (" note: signer re-encoded " + $s.Name + " (BOM/line endings); text is unchanged") }
329
+ }
330
+
331
+ # --- every signed input must be pure ASCII --------------------------------
332
+ # These files ship WITHOUT a BOM, so Windows PowerShell 5.1 decodes them with
333
+ # the machine's ANSI codepage. The Authenticode digest is taken over the
334
+ # DECODED text, so one non-ASCII byte makes a signature that verifies on the
335
+ # signing machine and fails on a customer machine in another locale. The
336
+ # existing guard in windowsHiddenSupervisor.test.ts covers string literals
337
+ # only, because that is all that breaks PARSING - comments break SIGNING.
338
+ foreach ($s in $subjects) {
339
+ $bytes = [IO.File]::ReadAllBytes($s.Unsigned)
340
+ if ($bytes | Where-Object { $_ -gt 127 }) {
341
+ Fail "$($s.Name): contains non-ASCII bytes and ships without a BOM - its signature is locale-dependent"
342
+ }
343
+ }
344
+
345
+ # --- turn the GPO on ------------------------------------------------------
346
+ if ($hadPolicyKey) {
347
+ $priorPolicy = Get-RegValue $PolicyKey 'ExecutionPolicy'
348
+ $priorEnableScripts = Get-RegValue $PolicyKey 'EnableScripts'
349
+ } else {
350
+ New-Item -Path $PolicyKey -Force | Out-Null
351
+ }
352
+ Set-ItemProperty -Path $PolicyKey -Name 'EnableScripts' -Value 1 -Type DWord
353
+ Set-ItemProperty -Path $PolicyKey -Name 'ExecutionPolicy' -Value 'AllSigned' -Type String
354
+
355
+ $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
356
+ # No embedded double quotes: the native command-line parser strips them and
357
+ # PowerShell then reads a bare `=` as an assignment (InvalidLeftHandSide).
358
+ $listed = & $ps -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command 'Get-ExecutionPolicy -List | Out-String'
359
+ Say ("Scopes seen by a child with -ExecutionPolicy Bypass:`r`n" + ((@($listed) -join [Environment]::NewLine).Trim()))
360
+ $effective = ((& $ps -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command 'Get-ExecutionPolicy') | Out-String).Trim()
361
+ Say "Effective policy inside -ExecutionPolicy Bypass: $effective"
362
+ if ($effective -ne 'AllSigned') {
363
+ Fail "MachinePolicy did not take effect (effective policy is '$effective') - every result below would be meaningless"
364
+ throw 'VACUOUS: the AllSigned machine policy did not apply.'
365
+ }
366
+
367
+ # =========================================================================
368
+ # CONTROL A - unsigned must be BLOCKED
369
+ #
370
+ # The canary is EXECUTED (`powershell -File`), which is unambiguously
371
+ # policy-checked; that result is mandatory. The non-executing Get-Command
372
+ # probe used on the shipped scripts is validated against the same unsigned
373
+ # canary first: if it does not report 'blocked' there, the technique does not
374
+ # observe the gate and the shipped-file assertions fall back to the
375
+ # signature + trusted-publisher decision PowerShell itself makes. Which mode
376
+ # was used is printed, never inferred.
377
+ # =========================================================================
378
+ Say '--- control A: unsigned under MachinePolicy=AllSigned ---'
379
+ $canaryUnsignedPs1 = Join-Path $unsignedDir 'tb-canary.ps1'
380
+ $rc = Test-ScriptExecution $canaryUnsignedPs1
381
+ Say " A canary EXECUTION (unsigned) $rc"
382
+ if ($rc -ne 'blocked') { Fail 'VACUOUS: an unsigned canary EXECUTED under MachinePolicy=AllSigned' }
383
+
384
+ # Validate the non-executing probe against that same unsigned canary before
385
+ # trusting it on the shipped scripts. Measured on windows-latest, the
386
+ # Get-Command/.ScriptBlock route reports 'allowed' for a file the policy really
387
+ # does block - command discovery does NOT run the policy check - so it is not
388
+ # used. The unbindable-parameter route does observe the gate.
389
+ $probeOnCanary = Test-ScriptPolicyByBinding $canaryUnsignedPs1
390
+ $policyProbeUsable = ($probeOnCanary -eq 'blocked')
391
+ Say (" A non-executing probe on the same unsigned canary: $probeOnCanary" +
392
+ $(if ($policyProbeUsable) { ' -> probe IS gated; shipped scripts get the direct assertion' }
393
+ else { ' -> probe is NOT gated; shipped scripts fall back to the signature/trust decision' }))
394
+
395
+ foreach ($s in ($subjects | Where-Object { $_.Kind -eq 'ps1' })) {
396
+ if (-not ($policyProbeUsable -and $s.Probeable)) { continue }
397
+ $r = Test-ScriptPolicyByBinding $s.Unsigned
398
+ Say (" A {0,-30} {1}" -f $s.Name, $r)
399
+ if ($r -ne 'blocked') { Fail "control A did not fire for $($s.Name): $r" }
400
+ }
401
+
402
+ # =========================================================================
403
+ # CONTROL B - signed but publisher not trusted must be BLOCKED
404
+ # =========================================================================
405
+ Say '--- control B: signed, publisher NOT yet in TrustedPublisher ---'
406
+ $rc = Test-ScriptExecution (Join-Path $signedDir 'tb-canary.ps1')
407
+ Say " B canary EXECUTION (signed, untrusted publisher) $rc"
408
+ if ($rc -ne 'blocked') { Fail 'control B did not fire: a signature from an UNTRUSTED publisher was executed under AllSigned' }
409
+
410
+ foreach ($s in ($subjects | Where-Object { $_.Kind -eq 'ps1' })) {
411
+ $sig = Get-AuthenticodeSignature -LiteralPath $s.Signed
412
+ if ($sig.Status -ne 'Valid') { Fail "$($s.Name): Authenticode status is $($sig.Status), expected Valid"; continue }
413
+ # Only the production signature can carry a countersignature - the hermetic
414
+ # mode signs with a throwaway cert and no timestamp authority. In production
415
+ # this is not optional: the Trusted Signing leaf is issued for ~72 hours
416
+ # (measured on the live artifact: notBefore Aug 12 20:09:38, notAfter Aug 15
417
+ # 20:09:38), so an untimestamped signature is dead within days.
418
+ if ($AlreadySigned -and -not $sig.TimeStamperCertificate) {
419
+ Fail "$($s.Name): signed but NOT RFC3161 timestamped - the Trusted Signing leaf lives ~3 days, so this stops validating almost immediately"
420
+ }
421
+ if ($policyProbeUsable -and $s.Probeable) {
422
+ $r = Test-ScriptPolicyByBinding $s.Signed
423
+ Say (" B {0,-30} {1}" -f $s.Name, $r)
424
+ if ($r -ne 'blocked') { Fail "control B did not fire for $($s.Name): untrusted publisher was admitted ($r)" }
425
+ } else {
426
+ Say (" B {0,-30} Authenticode={1} timestamped={2}" -f $s.Name, $sig.Status, [bool]$sig.TimeStamperCertificate)
427
+ }
428
+ }
429
+
430
+ # =========================================================================
431
+ # trust the publisher - PUBLIC certificate, taken from the signed file
432
+ # =========================================================================
433
+ $signerCert = (Get-AuthenticodeSignature -LiteralPath (Join-Path $signedDir 'tb-canary.ps1')).SignerCertificate
434
+ if (-not $signerCert) { throw 'could not read a signer certificate off the signed canary' }
435
+ Say ("Trusting publisher: " + $signerCert.Subject)
436
+ Add-CertToStore -Cert $signerCert -StoreName 'TrustedPublisher'
437
+ $addedThumbprints.Add('TrustedPublisher|' + $signerCert.Thumbprint)
438
+
439
+ # =========================================================================
440
+ # ASSERTION C - the shipped scripts are admitted. This is the deliverable.
441
+ # =========================================================================
442
+ Say '--- assertion C: shipped scripts under MachinePolicy=AllSigned ---'
443
+ $rc = Test-ScriptExecution (Join-Path $signedDir 'tb-canary.ps1')
444
+ Say " C canary EXECUTION (signed, trusted) $rc"
445
+ if ($rc -ne 'allowed') { Fail 'a signed, trusted canary did not execute under AllSigned' }
446
+
447
+ foreach ($s in ($subjects | Where-Object { $_.Kind -eq 'ps1' })) {
448
+ if ($policyProbeUsable -and $s.Probeable) {
449
+ $r = Test-ScriptPolicyByBinding $s.Signed
450
+ Say (" C {0,-30} {1} (policy gate, body not run)" -f $s.Name, $r)
451
+ if ($r -ne 'allowed') { Fail "$($s.Name) is STILL blocked under AllSigned after signing: $r" }
452
+ } else {
453
+ # The two inputs to PowerShell's AllSigned decision, asserted directly.
454
+ $sig = Get-AuthenticodeSignature -LiteralPath $s.Signed
455
+ $trusted = $sig.SignerCertificate -and ($sig.SignerCertificate.Thumbprint -eq $signerCert.Thumbprint)
456
+ Say (" C {0,-30} Valid={1} publisher-trusted={2}" -f $s.Name, ($sig.Status -eq 'Valid'), $trusted)
457
+ if ($sig.Status -ne 'Valid') { Fail "$($s.Name): Authenticode status is $($sig.Status)" }
458
+ if (-not $trusted) { Fail "$($s.Name): signed by a publisher other than the one proved to run under AllSigned" }
459
+ }
460
+ }
461
+
462
+
463
+ # =========================================================================
464
+ # E - is publisher trust DURABLE, or does it die with the 72-hour leaf?
465
+ #
466
+ # This is the question that decides whether "the admin trusts our publisher
467
+ # once" is a real enterprise story or a weekly chore. The Trusted Signing leaf
468
+ # is reissued about every 72 hours, so if AllSigned matches the LEAF in
469
+ # TrustedPublisher, every release needs a fresh trust deployment. If it accepts
470
+ # an issuer in the chain, one GPO covers every future release.
471
+ #
472
+ # Measured, not asserted, and not a pass/fail: it is a property of Windows, not
473
+ # of our code. Recorded here so the answer is a CI artifact rather than folklore.
474
+ # =========================================================================
475
+ Say '--- E: does trusting the ISSUER (not the 72h leaf) satisfy AllSigned? ---'
476
+ $issuer = $null
477
+ try {
478
+ $chain = New-Object Security.Cryptography.X509Certificates.X509Chain
479
+ $chain.ChainPolicy.RevocationMode = 'NoCheck'
480
+ $null = $chain.Build($signerCert)
481
+ if ($chain.ChainElements.Count -gt 1) { $issuer = $chain.ChainElements[1].Certificate }
482
+ } catch { Say " E could not build the certificate chain: $($_.Exception.Message)" }
483
+
484
+ if (-not $issuer) {
485
+ Say ' E no issuer above the leaf (self-signed harness cert) - not measurable in this mode'
486
+ } else {
487
+ Remove-CertFromStore -Thumbprint $signerCert.Thumbprint -StoreName 'TrustedPublisher'
488
+ Add-CertToStore -Cert $issuer -StoreName 'TrustedPublisher'
489
+ $addedThumbprints.Add('TrustedPublisher|' + $issuer.Thumbprint)
490
+ $rIssuer = Test-ScriptExecution (Join-Path $signedDir 'tb-canary.ps1')
491
+ Say (" E issuer trusted instead of leaf: " + $issuer.Subject)
492
+ Say (" E signed canary EXECUTION with ISSUER trusted: $rIssuer")
493
+ if ($rIssuer -eq 'allowed') {
494
+ Say ' E => publisher trust is DURABLE: one GPO deployment of the issuer covers every future release.'
495
+ } else {
496
+ Say ' E => publisher trust binds the LEAF, which rotates about every 72 hours. A one-time trust'
497
+ Say ' E deployment does NOT survive a release; an AllSigned estate needs a publisher rule that'
498
+ Say ' E matches on subject (AppLocker/WDAC) or a per-release trust push.'
499
+ }
500
+ # Put the leaf back so any later assertion sees the state assertion C proved.
501
+ Remove-CertFromStore -Thumbprint $issuer.Thumbprint -StoreName 'TrustedPublisher'
502
+ Add-CertToStore -Cert $signerCert -StoreName 'TrustedPublisher'
503
+ }
504
+
505
+ # =========================================================================
506
+ # CONTROL D - tamper with a signed file, it must go back to BLOCKED
507
+ # =========================================================================
508
+ Say '--- control D: signed then tampered ---'
509
+ # Prepending a comment leaves the script functionally identical and still
510
+ # breaks the digest, so a 'blocked' here can only come from the signature.
511
+ $tampered = Join-Path $Work 'tampered.ps1'
512
+ Copy-Item -LiteralPath (Join-Path $signedDir 'tb-canary.ps1') -Destination $tampered -Force
513
+ $t = [IO.File]::ReadAllText($tampered)
514
+ [IO.File]::WriteAllText($tampered, ("# tampered`r`n" + $t), (New-Object System.Text.UTF8Encoding($false)))
515
+ $r = Test-ScriptExecution $tampered
516
+ Say " D tampered canary EXECUTION $r"
517
+ if ($r -ne 'blocked') { Fail "control D did not fire: a TAMPERED signed script ran under AllSigned ($r)" }
518
+
519
+ # =========================================================================
520
+ # VBScript - a different gate (WSH TrustPolicy), same shape
521
+ # =========================================================================
522
+ if ($VbsPath.Count -gt 0 -or (Test-Path -LiteralPath (Join-Path $signedDir 'tb-canary.vbs'))) {
523
+ Say '--- VBScript: WSH TrustPolicy=2 (run only signed, trusted scripts) ---'
524
+ if (-not (Test-Path $WshKey)) { New-Item -Path $WshKey -Force | Out-Null }
525
+ $priorTrustPolicy = Get-RegValue $WshKey 'TrustPolicy'
526
+ $priorUseWinSafer = Get-RegValue $WshKey 'UseWINSAFER'
527
+ # UseWINSAFER=1 makes WSH defer to software restriction policy and IGNORE
528
+ # TrustPolicy entirely - the exact way this probe could silently measure
529
+ # nothing.
530
+ Set-ItemProperty -Path $WshKey -Name 'UseWINSAFER' -Value 0 -Type DWord
531
+ Set-ItemProperty -Path $WshKey -Name 'TrustPolicy' -Value 2 -Type DWord
532
+
533
+ $rv = Test-VbsExecution (Join-Path $unsignedDir 'tb-canary.vbs')
534
+ Say " A vbs canary EXECUTION (unsigned) $rv"
535
+ if ($rv -ne 'blocked') { Fail 'VACUOUS: an unsigned .vbs executed under WSH TrustPolicy=2' }
536
+
537
+ $rv = Test-VbsExecution (Join-Path $signedDir 'tb-canary.vbs')
538
+ Say " C vbs canary EXECUTION (signed) $rv"
539
+ if ($rv -ne 'allowed') { Fail 'a signed, trusted .vbs did not execute under WSH TrustPolicy=2' }
540
+
541
+ foreach ($s in ($subjects | Where-Object { $_.Kind -eq 'vbs' })) {
542
+ $sig = Get-AuthenticodeSignature -LiteralPath $s.Signed
543
+ Say (" C {0,-30} Authenticode={1}" -f $s.Name, $sig.Status)
544
+ if ($sig.Status -ne 'Valid') { Fail "$($s.Name): Authenticode status is $($sig.Status), expected Valid" }
545
+ }
546
+ }
547
+
548
+ # =========================================================================
549
+ # .cmd - measured, not assumed
550
+ # =========================================================================
551
+ if ($CmdPath.Count -gt 0) {
552
+ Say '--- .cmd: no Authenticode SIP, and no execution policy to block it ---'
553
+ $probeCmd = Join-Path $Work 'tb-canary.cmd'
554
+ [IO.File]::WriteAllText($probeCmd, "@echo off`r`n> %1 echo ran`r`n", (New-Object System.Text.ASCIIEncoding))
555
+ $signable = 'no'
556
+ try {
557
+ $c = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert -ErrorAction SilentlyContinue | Select-Object -First 1
558
+ if ($c) {
559
+ $res = Set-AuthenticodeSignature -FilePath $probeCmd -Certificate $c -ErrorAction Stop
560
+ if ($res.Status -eq 'Valid') { $signable = 'YES' }
561
+ } else { $signable = 'not-attempted(no local cert)' }
562
+ } catch { $signable = 'no (' + $_.Exception.Message.Split([char]10)[0].Trim() + ')' }
563
+ Say " .cmd Authenticode signable: $signable"
564
+
565
+ $rcmd = Test-CmdExecution $probeCmd
566
+ Say " .cmd EXECUTION under MachinePolicy=AllSigned: $rcmd"
567
+ if ($rcmd -ne 'allowed') {
568
+ Fail 'unexpected: a .cmd was blocked under AllSigned - the runbook claim that AllSigned does not gate batch files is wrong'
569
+ }
570
+ foreach ($p in $CmdPath) {
571
+ Say (" .cmd shipped, unsignable by design: " + (Split-Path -Leaf $p))
572
+ }
573
+ }
574
+
575
+ } finally {
576
+ # Restore both policies. Ephemeral CI does not need this; a developer box does.
577
+ try {
578
+ if ($hadPolicyKey) {
579
+ if ($null -ne $priorPolicy) { Set-ItemProperty -Path $PolicyKey -Name 'ExecutionPolicy' -Value $priorPolicy -Type String }
580
+ else { Remove-ItemProperty -Path $PolicyKey -Name 'ExecutionPolicy' -ErrorAction SilentlyContinue }
581
+ if ($null -ne $priorEnableScripts) { Set-ItemProperty -Path $PolicyKey -Name 'EnableScripts' -Value $priorEnableScripts -Type DWord }
582
+ else { Remove-ItemProperty -Path $PolicyKey -Name 'EnableScripts' -ErrorAction SilentlyContinue }
583
+ } else {
584
+ Remove-Item -Path $PolicyKey -Recurse -Force -ErrorAction SilentlyContinue
585
+ }
586
+ if ($null -ne $priorTrustPolicy) { Set-ItemProperty -Path $WshKey -Name 'TrustPolicy' -Value $priorTrustPolicy -Type DWord -ErrorAction SilentlyContinue }
587
+ elseif (Test-Path $WshKey) { Remove-ItemProperty -Path $WshKey -Name 'TrustPolicy' -ErrorAction SilentlyContinue }
588
+ if ($null -ne $priorUseWinSafer) { Set-ItemProperty -Path $WshKey -Name 'UseWINSAFER' -Value $priorUseWinSafer -Type DWord -ErrorAction SilentlyContinue }
589
+ elseif (Test-Path $WshKey) { Remove-ItemProperty -Path $WshKey -Name 'UseWINSAFER' -ErrorAction SilentlyContinue }
590
+ foreach ($entry in $addedThumbprints) {
591
+ $parts = $entry.Split('|')
592
+ Remove-CertFromStore -Thumbprint $parts[1] -StoreName $parts[0]
593
+ }
594
+ } catch {
595
+ Write-Host "[allsigned] cleanup warning: $($_.Exception.Message)" -ForegroundColor Yellow
596
+ }
597
+ Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue
598
+ }
599
+
600
+ if ($failures.Count -gt 0) {
601
+ Write-Host ''
602
+ Write-Host "[allsigned] $($failures.Count) failure(s):" -ForegroundColor Red
603
+ foreach ($f in $failures) { Write-Host " - $f" -ForegroundColor Red }
604
+ exit 1
605
+ }
606
+
607
+ Say 'PASSED - unsigned is blocked, untrusted-publisher is blocked, tampered is blocked, and every shipped script is admitted.'
608
+ exit 0