Skip to content

Commit c110054

Browse files
committed
harden(scaffold): whole-branch-review fixes — EgressAllowlist hostname validation, detach-ordering + timeout test hygiene, deps-persistence/seed comments
SEC-1: validate each EgressAllowlist entry against ^(?i)\.?[a-z0-9][a-z0-9.-]*$ in Assert-TierProfileValid (ProfileLoader.ps1) before it reaches the builder's Squid dstdomain ACL (SeedBuilder substitutes the allowlist with no escaping; a newline-bearing entry could inject http_access allow all). Runs on bare tiers AND the merged builder. All shipped profiles (tier0/tier1/ralph/firefox/builder) still load. New reject + accept tests; SeedBuilder xref comment. TR-2: the detach-before-hash test used IndexOf ordering only, which is vacuously true when an op is absent (-1 -BeLessThan positive). Added Should -Contain guards for RemoveHardDiskDrive + GetVhdxImageHash before the ordering assertion. TR-3: the two builder timeout tests leaked a "did not power off" WARNING into Pester output. Capture it (-WarningVariable) and assert it (a real timeout-path check), and silence the host leak (-WarningAction SilentlyContinue). SPEC-1/ci-1: BuilderVM.ps1 comments claimed deps.vhdx "is left on disk" — but the default DepsDiskPath == OutputDiskPath IS in CreatedDisks and IS deleted at teardown. Rewrote the .DESCRIPTION + finally-block comments: persistence is LIVE-only pending the Phase-6 Copy-Item; the whole-image hash is the durable artifact today. 2.2 docstring: SeedBuilder header "Two shapes" -> "Three" (DISK-OFFLINE, DISK-BUILDER [Disk+SquidSniProxy], SERIAL). SEC-2 (comment only): the builder seed redirects TCP 80/443 to Squid but does NOT yet enforce BlockProtocols (DNS/53, QUIC/UDP-443, DoH/DoT) — noted as Phase-6; no enforcement wired. ci-2: SeedIso is genuinely unused in the disk-mode/builder path (New-WorkloadSeedDisk builds the seed onto a CIDATA data disk; SeedIso is consumed only by the serial/DVD path). Added a clarifying NOTE in builder.psd1; kept the key (the loader carries it, mirrors firefox.psd1).
1 parent 3947f2f commit c110054

6 files changed

Lines changed: 92 additions & 16 deletions

File tree

profiles/builder.psd1

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@
5252
# deps + a per-file SHA-256 manifest under /mnt/out (-> the OUTPUT disk -> deps.vhdx).
5353
Entrypoint = 'python3 /mnt/in/fetch_deps.py --spec /mnt/in/deps-spec.json --out /mnt/out'
5454

55-
# The cloud-init NoCloud CIDATA seed (2.2 builds the builder variant with the Squid proxy +
56-
# dep-fetch runner). Attached read-only at provision; ejected by the seal. Not secret-shaped.
55+
# The cloud-init NoCloud CIDATA seed path. ci-2 NOTE: this is UNUSED in the disk-mode/builder path —
56+
# the builder is WorkloadMode='Disk', and New-WorkloadSeedDisk (Workload.ps1) builds the seed CONTENT
57+
# in-line onto a recorded CIDATA *data disk* ('<name>-cidata.vhdx' under the storage root, RC6) that
58+
# survives the seal; it never reads this SeedIso file. SeedIso is consumed ONLY by the serial/DVD path
59+
# (New-CidataSeed -Destination default + Add-SandboxSeed). Kept (not dropped) because the loader still
60+
# carries the key (mirrors firefox.psd1, which also declares it for disk-mode) and it documents the
61+
# canonical seed path; it is harmless in disk mode. Not secret-shaped.
5762
SeedIso = 'C:\sandbox\assets\cidata-seed.iso'
5863
}

scripts/lib/BuilderVM.ps1

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,20 @@
2222
locked; the fake THROWS on GetVhdxImageHash while still attached).
2323
HASH GetVhdxImageHash — whole-file SHA-256 in USER-SPACE (NEVER Mount-VHD).
2424
EMIT Record DepsDiskPath + WholeImageHash + Status=Success.
25-
TEARDOWN Remove-Sandbox in a finally — leaves deps.vhdx (NOT in CreatedDisks; M2).
25+
TEARDOWN Remove-Sandbox in a finally. NOTE: in the DEFAULT case $DepsDiskPath == OutputDiskPath,
26+
which New-WorkloadDisks PUT in CreatedDisks, so Remove-Sandbox -DeleteDisks DELETES it.
27+
deps.vhdx persistence is LIVE-only, pending the Phase-6 Copy-Item to a stable path that
28+
is NOT in CreatedDisks (the commented-out Copy-Item below the hash). Until then the
29+
whole-image hash is the durable artifact; the deps .vhdx itself does not survive teardown.
2630
2731
DESIGN DECISIONS (resolved — do not redesign):
2832
* No host-mount: the integrity artifact is the whole-image hash (GetVhdxImageHash, raw).
2933
* Success = clean power-off: -not $wait.TimedOut -> Status='Success'; timeout -> 'Failed'.
3034
The exitcode/per-file-manifest outbox read DEFERS to Phase 4.
31-
* deps.vhdx persists (M2): teardown LEAVES deps.vhdx (not in CreatedDisks). The real file
32-
copy to a stable path is LIVE-only (Phase 6); in the mock set DepsDiskPath to the
33-
OUTPUT path and record the hash.
35+
* deps.vhdx persistence is LIVE-only (Phase 6): in the DEFAULT case DepsDiskPath == OutputDiskPath,
36+
which IS in CreatedDisks, so teardown's Remove-Sandbox -DeleteDisks DELETES it. The Phase-6
37+
Copy-Item to a stable path NOT in CreatedDisks is what will make deps.vhdx survive; until then the
38+
mock just sets DepsDiskPath to the OUTPUT path and records the hash (the durable artifact).
3439
3540
Dot-source this file AFTER Invoke-Voidseal.ps1 (which dot-sources all engine libs:
3641
HyperVBackend.ps1, ProfileLoader.ps1, Provisioner.ps1, Sealer.ps1, Runner.ps1,
@@ -222,10 +227,12 @@ function Invoke-BuilderVM {
222227
}
223228
finally {
224229
# --- DESTROYED: teardown ALWAYS runs (no orphaned VM/disk/switch) ---
225-
# LEAVE deps.vhdx (M2): the deps disk is the builder's persistent output artifact, NOT
226-
# in CreatedDisks (we never added it). Remove-Sandbox -DeleteDisks only targets
227-
# CreatedDisks (the system disk + INPUT + OUTPUT + seed disk), so deps.vhdx is left
228-
# on disk whether we're using the stable $DepsDiskPath or the working OutputDiskPath.
230+
# deps.vhdx persistence is LIVE-only (Phase 6). In the DEFAULT case $DepsDiskPath ==
231+
# OutputDiskPath, which New-WorkloadDisks PUT in CreatedDisks (the system disk + INPUT +
232+
# OUTPUT + seed disk), so Remove-Sandbox -DeleteDisks DELETES it here — deps.vhdx does NOT
233+
# survive teardown today. The Phase-6 Copy-Item (commented out above the EMIT) copies the
234+
# OUTPUT .vhdx to a stable path NOT in CreatedDisks; only THEN does the deps artifact persist.
235+
# Until then the whole-image hash is the durable record, not the .vhdx file.
229236
try {
230237
if ($null -ne $descriptor) {
231238
Remove-Sandbox -Name $Name -DeleteDisks -Descriptor $descriptor -Backend $Backend

scripts/lib/ProfileLoader.ps1

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,24 @@ function Assert-TierProfileValid {
282282
# EgressAllowlist must be an array (already normalized by caller, but re-check shape).
283283
$allowlist = @($Profile['EgressAllowlist'])
284284

285+
# SEC-1: hostname-charset validation. The builder substitutes ($allowlist -join ' ') straight into
286+
# a Squid `dstdomain` ACL (SeedBuilder.ps1) with NO escaping; an entry bearing a newline (or quote/
287+
# whitespace) could inject a directive such as `http_access allow all` and defeat the egress ACL.
288+
# Validate EACH entry against a strict hostname charset (optional leading dot for a domain-suffix
289+
# match; the charset excludes whitespace/newline/quote/slash), failing closed and naming the bad
290+
# entry. Runs on bare tiers AND the merged builder, so the allowlist is gatekept before it ever
291+
# reaches the Squid seed. (Tier>=2 enforces an EMPTY allowlist below, so this is a no-op there.)
292+
$hostnameRe = '^(?i)\.?[a-z0-9][a-z0-9.-]*$'
293+
foreach ($entry in $allowlist) {
294+
$e = [string]$entry
295+
if ($e -notmatch $hostnameRe) {
296+
throw ("Schema validation: '$Context' field 'EgressAllowlist' has an invalid entry '$e' " +
297+
"(must match $hostnameRe — a hostname/domain-suffix, optional leading dot, no whitespace/" +
298+
"newline/quote/slash). An unvalidated entry could inject directives into the builder's " +
299+
"Squid dstdomain ACL. Failing closed.")
300+
}
301+
}
302+
285303
# HostChannels must be a hashtable.
286304
if (-not ($Profile['HostChannels'] -is [System.Collections.IDictionary])) {
287305
throw "Schema validation: '$Context' field 'HostChannels' must be a hashtable."

scripts/lib/SeedBuilder.ps1

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
55
.DESCRIPTION
66
Builds the first-boot **NoCloud `CIDATA` seed ISO** a sandbox guest consumes on its FIRST boot.
7-
Two shapes, selected by the profile's `WorkloadMode`:
7+
Three shapes, selected by the profile's `WorkloadMode` (+ `EgressMode` for the builder variant):
88
9-
* DISK — the disk-passing workload runner (guest-images/debian-12-cloud.md §2a). A systemd
10-
oneshot mounts the host-pre-formatted exFAT data disks by LABEL (INPUT ro -> /mnt/in,
9+
* DISK-OFFLINE — the disk-passing workload runner (guest-images/debian-12-cloud.md §2a). A
10+
systemd oneshot mounts the host-pre-formatted exFAT data disks by LABEL (INPUT ro -> /mnt/in,
1111
OUTPUT rw -> /mnt/out), runs the profile's `Entrypoint` as the non-root `sandbox` user,
1212
writes `result.html` + the `result.exitcode` sentinel onto OUTPUT, flushes, and self-powers
1313
off. The seed builder substitutes the profile's `Entrypoint` for the `__ENTRYPOINT__` token.
14+
* DISK-BUILDER (`WorkloadMode='Disk'` + `EgressMode='SquidSniProxy'`) — the Tier-1 net-restricted
15+
builder variant: same disk-passing runner PLUS a transparent Squid SNI domain-ACL proxy that
16+
gatekeeps the guest's 80/443 egress to the `EgressAllowlist` only (substituted into the Squid
17+
`dstdomain` ACL). Fetches deps over Squid into /mnt/out, writes a manifest, self-powers-off.
1418
* SERIAL (default) — the §2 baseline: serial-getty AUTOLOGIN on ttyS0 (the Runner's command
1519
seam, which does NOT authenticate), a non-root run-user, and bubblewrap.
1620
@@ -210,6 +214,10 @@ write_files:
210214
# --- Squid transparent proxy setup ---
211215
systemctl restart squid 2>/dev/null || true
212216
# Redirect outbound HTTP/HTTPS through Squid (transparent intercept)
217+
# SEC-2 (HONESTY — deferred to Phase 6): this only REDIRECTS TCP 80/443 to Squid. It does NOT yet
218+
# enforce tier1's BlockProtocols (DNS/53, QUIC/UDP-443, DoH/DoT) — only 80/443 are gatekept by the
219+
# domain ACL; DNS and any non-80/443 egress are NOT default-dropped. Full default-DROP egress
220+
# enforcement (the BlockProtocols set) is Phase-6 work and is intentionally NOT wired here.
213221
iptables -t nat -A OUTPUT -p tcp --dport 80 -m owner ! --uid-owner proxy -j REDIRECT --to-port 3129
214222
iptables -t nat -A OUTPUT -p tcp --dport 443 -m owner ! --uid-owner proxy -j REDIRECT --to-port 3130
215223
@@ -344,6 +352,9 @@ function New-CidataUserData {
344352
if ($null -eq $allowlist -or @($allowlist).Count -eq 0) {
345353
throw "New-CidataUserData: a builder Disk+SquidSniProxy profile must have a non-empty EgressAllowlist (allowlist is empty — fail-closed)."
346354
}
355+
# SEC-1: each allowlist entry's charset (no whitespace/newline/quote) is validated at LOAD time
356+
# in Assert-TierProfileValid (ProfileLoader.ps1) before it ever reaches here, so a newline-bearing
357+
# entry cannot inject a directive (e.g. `http_access allow all`) into the dstdomain ACL below.
347358
$aclLine = ($allowlist -join ' ')
348359
$ud = $script:CidataBuilderRunnerTemplate.Replace('__ENTRYPOINT__', $entrypoint).Replace('__SQUID_ALLOWLIST_ACL__', $aclLine)
349360
return (ConvertTo-LfText -Text $ud)

tests/BuilderVM.Tests.ps1

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,22 @@ Describe 'Invoke-BuilderVM — Tier-1 builder orchestration (Phase 2.4)' {
4949
$r = Invoke-BuilderVM -Profile "$script:SkillRoot/profiles/builder.psd1" -Name 'bld2' -Backend $fake -DepsDiskPath (Join-Path $TestDrive 'd2.vhdx')
5050
$r.Status | Should -Be 'Success' # GetVhdxImageHash THROWS if still attached -> Success proves detach-before-hash
5151
$ops = @($fake.FakeCallLog | Where-Object { $_.Op -in @('RemoveHardDiskDrive','GetVhdxImageHash') } | ForEach-Object { $_.Op })
52+
# TR-2: assert BOTH ops actually ran BEFORE the IndexOf ordering check — IndexOf returns -1 when an
53+
# op is absent, and `-1 -BeLessThan <positive>` is vacuously TRUE, so the ordering assertion alone
54+
# would pass even if the detach never happened (mirror InvokeVoidseal.Tests.ps1:352-363).
55+
$ops | Should -Contain 'RemoveHardDiskDrive' -Because 'the OUTPUT/deps disk must be detached'
56+
$ops | Should -Contain 'GetVhdxImageHash' -Because 'the whole-image hash must run'
5257
($ops.IndexOf('RemoveHardDiskDrive')) | Should -BeLessThan ($ops.IndexOf('GetVhdxImageHash'))
5358
}
5459

5560
It 'a hung builder (SimulateNeverOff) times out -> Status=Failed, NO deps artifact, no hash' {
5661
$fake = New-FakeHyperVBackend -SimulateNeverOff -SimulateDepsImageBlob ([byte[]](1..8))
57-
$r = Invoke-BuilderVM -Profile "$script:SkillRoot/profiles/builder.psd1" -Name 'bld3' -Backend $fake -WorkloadTimeoutSeconds 0 -DepsDiskPath (Join-Path $TestDrive 'd3.vhdx')
62+
# TR-3: capture the lifecycle-abort WARNING (instead of letting it leak into Pester output) and
63+
# assert it — turning the warning into a real behavioral check that the timeout path was hit.
64+
$r = Invoke-BuilderVM -Profile "$script:SkillRoot/profiles/builder.psd1" -Name 'bld3' -Backend $fake -WorkloadTimeoutSeconds 0 -DepsDiskPath (Join-Path $TestDrive 'd3.vhdx') -WarningVariable wv -WarningAction SilentlyContinue
5865
$r.Status | Should -Be 'Failed'
5966
$r.WholeImageHash | Should -BeNullOrEmpty
67+
(@($wv) -join "`n") | Should -Match 'did not power off' -Because 'the timeout path must emit the lifecycle-abort warning'
6068
}
6169

6270
It 'a non-builder profile (EgressMode != SquidSniProxy) is refused' {
@@ -80,8 +88,10 @@ Describe 'Invoke-BuilderVM — teardown leaves deps.vhdx, removes other disks' {
8088

8189
It 'the VM is removed after a timed-out builder run (no orphan)' {
8290
$fake = New-FakeHyperVBackend -SimulateNeverOff
83-
$r = Invoke-BuilderVM -Profile "$script:SkillRoot/profiles/builder.psd1" -Name 'bld-timeout-td' -Backend $fake -WorkloadTimeoutSeconds 0 -DepsDiskPath (Join-Path $TestDrive 'deps-t2.vhdx')
91+
# TR-3: capture + assert the lifecycle-abort warning (no leak into Pester output).
92+
$r = Invoke-BuilderVM -Profile "$script:SkillRoot/profiles/builder.psd1" -Name 'bld-timeout-td' -Backend $fake -WorkloadTimeoutSeconds 0 -DepsDiskPath (Join-Path $TestDrive 'deps-t2.vhdx') -WarningVariable wv -WarningAction SilentlyContinue
8493
$r.Status | Should -Be 'Failed'
94+
(@($wv) -join "`n") | Should -Match 'did not power off' -Because 'the timeout path must emit the lifecycle-abort warning'
8595
(& $fake.GetVM @{ Name = 'bld-timeout-td' }) | Should -BeNullOrEmpty -Because 'the builder VM is removed even on a timeout'
8696
}
8797
}
@@ -119,7 +129,7 @@ Describe 'Invoke-BuilderVM — RC7: SetAutomaticCheckpoints called before StartV
119129

120130
It 'SetAutomaticCheckpoints(Enabled=$false) is applied before StartVM: the deps hash is non-empty (AutomaticCheckpoints=ON would hide the blob in a child .avhdx -> empty hash)' {
121131
# When AutomaticCheckpointsEnabled is still ON at StartVM, the fake routes the
122-
# DepsImageBlob into OutboxChildLayer (the .avhdx child — inaccessible to GetVhdxImageHash,
132+
# DepsImageBlob into DepsImageChildLayer (the .avhdx child — inaccessible to GetVhdxImageHash,
123133
# which reads the base). So GetVhdxImageHash would return SHA256 of empty bytes, NOT the
124134
# blob's hash. If RC7 is applied (Enabled=$false) before StartVM, the blob goes into
125135
# DepsImageRegion (the base layer) and the hash matches the blob. So a non-empty/correct

tests/ProfileLoader.Tests.ps1

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,31 @@ Describe 'Builder profile + SquidSniProxy egress (Phase 2.1)' {
626626
Test-AllowlistCoversHost -Allowlist @('huggingface.co') -HostName 'cdn-lfs.huggingface.co' | Should -BeFalse
627627
Test-AllowlistCoversHost -Allowlist @('pypi.org') -HostName 'pypi.org' | Should -BeTrue
628628
}
629+
It 'SEC-1: an EgressAllowlist entry bearing a newline (Squid ACL injection) is REFUSED fail-closed, naming the bad entry' {
630+
# A newline-bearing entry could inject `http_access allow all` into the builder's Squid dstdomain
631+
# ACL (SeedBuilder substitutes ($allowlist -join ' ') with no escaping). The load-time charset
632+
# check must reject it before it ever reaches the seed. Direct Assert-TierProfileValid on a
633+
# network-tier hashtable (a .psd1 cannot even carry a literal newline-in-a-string cleanly).
634+
$bad = @{
635+
Tier=1; Description='p'; Substrate='HyperV-Gen2'; Network='Internal'; EgressMode='NftablesAllowlist';
636+
EgressAllowlist=@("pypi.org`nhttp_access allow all"); Credentials='None'; GuestImage='debian-12'; Memory=2GB; Cpu=2;
637+
HostChannels=@{Clipboard=$false;Shares=$false;GuestServices=$false;EnhancedSession=$false};
638+
Capture='HostReadResultDir'; Extraction='HostReadResultDir'; Lifecycle='CreateDestroy';
639+
Controls=@(); ManagementChannel='Com1Serial'
640+
}
641+
{ Assert-TierProfileValid -Profile $bad -Context 'sec1' } |
642+
Should -Throw -ExpectedMessage '*EgressAllowlist*invalid entry*'
643+
}
644+
It 'SEC-1: a clean hostname AND a leading-dot domain-suffix entry are ACCEPTED (the regex passes real allowlists)' {
645+
$ok = @{
646+
Tier=1; Description='p'; Substrate='HyperV-Gen2'; Network='Internal'; EgressMode='NftablesAllowlist';
647+
EgressAllowlist=@('cas-bridge.xethub.hf.co', '.hf.co', 'pypi.org'); Credentials='None'; GuestImage='debian-12'; Memory=2GB; Cpu=2;
648+
HostChannels=@{Clipboard=$false;Shares=$false;GuestServices=$false;EnhancedSession=$false};
649+
Capture='HostReadResultDir'; Extraction='HostReadResultDir'; Lifecycle='CreateDestroy';
650+
Controls=@(); ManagementChannel='Com1Serial'
651+
}
652+
{ Assert-TierProfileValid -Profile $ok -Context 'sec1ok' } | Should -Not -Throw
653+
}
629654
It 'a workload overriding EgressMode to a NON-SquidSniProxy value is REFUSED' {
630655
$wl = Join-Path $TestDrive 'bad-egress.psd1'
631656
Set-Content -LiteralPath $wl -Encoding utf8 -Value @'

0 commit comments

Comments
 (0)