From fcbad44e0eef6fcbec700a7c4dec3db841c2cd5c Mon Sep 17 00:00:00 2001 From: Kelly Michels Date: Fri, 21 Aug 2026 21:43:40 -0500 Subject: [PATCH] feat(zdeploy): add the static deploy kind, and a test that keeps this repo sanitized (#55) Two things, both prompted by the same incident. STATIC KIND Plain static sites - no build, no container of their own; a shared web container serves them off disk, so shipping the files IS the deploy. Directories are staged to a sibling and swapped in with mv rather than copied in place, because a large media file uploaded in place is served half-written to anyone who requests it mid-copy. The swap is a rename, so the switch is atomic. Skips $JunkDirNames + .github + deploy.skipDirs, matching the docker kind rather than inventing a third convention. SANITIZATION TEST This repo is public and must stay standalone, but the toolkit is developed in a private checkout and copied here. Twice in three days a wholesale copy landed carrying real project names, internal hostnames, host disk figures, and references to scripts that exist only in the private copy. Both times a human reading the diff caught it - the control that fails exactly when a diff is 280 lines of good work with three bad words buried in it. So it is a test now. Seven rules: private project names, private product domains, private-only script names, local drive paths, the operator home path, the real key filename, and any IPv4 outside RFC 5737 documentation space and the private ranges. Two anti-vacuity guards, because a denylist that silently matches nothing is worse than no denylist: one asserts the file scan is non-empty, and one plants a known violation and requires the pattern to find it. The IP rule bounds on [\d.] rather than \d deliberately - this toolkit's own 5-segment version (v1.0.0.0.14) contains "0.0.0.14", which a plain digit boundary reads as an address. Verified against the real unsanitized copy that slipped through: 3 of the 7 rules fire, naming file and line. Tests: 231/231 (222 + 9 new). CHECKSUMS.txt refreshed. --- CHECKSUMS.txt | 2 +- tests/Sanitization.Tests.ps1 | 117 +++++++++++++++++++++++++++++++++++ zdeploy.ps1 | 43 ++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 tests/Sanitization.Tests.ps1 diff --git a/CHECKSUMS.txt b/CHECKSUMS.txt index 4d6483f..20a9a4b 100644 --- a/CHECKSUMS.txt +++ b/CHECKSUMS.txt @@ -8,7 +8,7 @@ dbd1b9c64fba16a308ffa8fa936f1bfda556b049aaa30ebe5400ca74b7e6aa5d zbackup_ec2.ps e03075f367a9ecee0f97438bc381044c74b7bd4e8cb4ff66b40048bba7ffd25a zchecksums.cmd 8382ad5f972405678b73616f608a80e3eb1814c927ba104e446d36c4f9f5c66d zchecksums.ps1 3d1064817ace57fe61c54104900775a0208fdf7e782043cced84b002347acb11 zdeploy.cmd -c3b5eeac28cb4c17a8b74638466a7e8529af64dba79b11ad46b3d78c36b6b81f zdeploy.ps1 +5610abe7d4e979b3d8cdabb50710f23969454b647f0ab4f90d7cd68cc7f5ee38 zdeploy.ps1 6fa05d7c47992801d0ad65095146764cc207b566dca85956b3152221ef9af368 zec2.cmd 72217c04e8975f46b489bd7e223f9fda926d982a8e418fc2825c6aa4657f73b7 zec2.ps1 d0702f372ec5e47e2632229c61b71a9184edf0775d6e23af74e3f919d13eadb9 zec2_rotatekeys.cmd diff --git a/tests/Sanitization.Tests.ps1 b/tests/Sanitization.Tests.ps1 new file mode 100644 index 0000000..111e99f --- /dev/null +++ b/tests/Sanitization.Tests.ps1 @@ -0,0 +1,117 @@ +# Evomedia.net Token Savers โ€” https://github.com/evomedia-net/evo.zscripts +# Created by Kelly Michels ยท dev@evomedia.net +# Licensed under the MIT License. See LICENSE. + +# Sanitization.Tests.ps1 โ€” this repo is public and must stay standalone. +# +# WHY THIS EXISTS +# --------------- +# The toolkit is developed in a private checkout and copied here. Twice in three +# days a wholesale copy landed carrying environment-specific detail: real +# project names, internal hostnames, host disk figures, and references to +# scripts that exist only in the private copy. Each time it was caught by a +# human reading the diff, which is exactly the control that fails when a diff is +# 280 lines of good work with three bad words buried in it. +# +# So it is a test. A copy that reintroduces private detail turns the suite red +# at the moment it happens rather than at review. +# +# WHAT IT CANNOT DO +# ----------------- +# This is a denylist, so it proves the absence of KNOWN patterns, not the +# absence of secrets. It is a regression net for a specific recurring mistake - +# not a substitute for reading what you publish. Add a pattern whenever a new +# private identifier appears; the cost of a stale entry is zero. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + + # Scanned: everything a reader of the published repo can see. Skipped: + # .git (history is out of scope here), releases/ (published zips are + # immutable by policy - rewriting one would break its checksum), and this + # file, which necessarily contains every pattern it looks for. + $script:Scanned = @( + Get-ChildItem -LiteralPath $script:RepoRoot -Recurse -File | + Where-Object { + $_.Extension -in @('.ps1', '.cmd', '.md', '.txt', '.json', '.bats', '.sh') -and + $_.FullName -notlike '*\.git\*' -and + $_.FullName -notlike '*\releases\*' -and + $_.Name -ne 'Sanitization.Tests.ps1' + } + ) + + # name -> what it is, so a failure explains itself + # pattern -> regex, case-insensitive + # Kept narrow on purpose: "evomedia.net" alone is legitimate here (the + # attribution header and the repo URL), so only the drive path and specific + # internal hosts are matched. + $script:Denied = @( + @{ Name = 'private project name'; Pattern = '\b(EvoCivilCode|EvoPlatform|DocketMail|SmartPlant\w*|ProvenSheet|evoehs|evoproven|evoaicc|evolocate|evoplatform)\b' } + @{ Name = 'private product domain'; Pattern = '\b(smartplantehs\.com|provensheet\.com|evoehs\.com|civilcode\.evomedia\.net|dashboard\.evomedia\.net|webmail\.evomedia\.net|mail-admin\.evomedia\.net|docketmail\.evomedia\.net|cardiff\.evomedia\.net|platform\.evomedia\.net|ai\.evomedia\.net|git\.evomedia\.net|analytics\.evomedia\.net)\b' } + @{ Name = 'private-only script'; Pattern = '\b(register_civilcode|register_docketmail|sp_seed_demo_prod|zpublish_stats|zcoverage|zmerge|zpull|zresume|swag_set_owner|provision_demo|apply_platform_config_fixes)\b' } + @{ Name = 'local drive path'; Pattern = '[A-Za-z]:\\\\?evomedia\.net' } + @{ Name = 'operator home path'; Pattern = '/home/ubuntu/' } + @{ Name = 'real pem key name'; Pattern = 'evomedia-prod\.pem' } + # RFC 5737 reserves 203.0.113.0/24 for documentation - that one is the + # correct placeholder and must stay allowed, as are loopback and the + # private ranges. Anything else that looks like a public IPv4 literal is + # suspect. + # + # The boundaries are [\d.] rather than \d on purpose: this toolkit's own + # 5-segment version (v1.0.0.0.14) contains "0.0.0.14", which a plain + # digit boundary happily reads as an address. Refusing a match that + # touches another dot rules out every version string without weakening + # detection of a real address, which is always delimited by whitespace + # or quotes. + @{ Name = 'non-documentation IP'; Pattern = '(?" -ForEach @( + @{ Name = 'private project name' } + @{ Name = 'private product domain' } + @{ Name = 'private-only script' } + @{ Name = 'local drive path' } + @{ Name = 'operator home path' } + @{ Name = 'real pem key name' } + @{ Name = 'non-documentation IP' } + ) { + $rule = $script:Denied | Where-Object { $_.Name -eq $Name } + $hits = Get-Hits -Pattern $rule.Pattern + $hits | Should -BeNullOrEmpty -Because "these look like private detail copied in from the internal toolkit:`n$($hits -join "`n")" + } + + It "still detects a planted violation" { + # Mutation check. Without this the suite passes just as happily when the + # patterns are broken as when the repo is clean - the failure mode that + # makes a denylist worthless. + $probe = Join-Path ([IO.Path]::GetTempPath()) ("sanitize-probe-" + [guid]::NewGuid().ToString("N") + ".ps1") + try { + Set-Content -LiteralPath $probe -Value '# deploy target /home/ubuntu/stack/example' -Encoding UTF8 + $found = Select-String -LiteralPath $probe -Pattern ($script:Denied | Where-Object { $_.Name -eq 'operator home path' }).Pattern + $found | Should -Not -BeNullOrEmpty + } + finally { Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue } + } +} diff --git a/zdeploy.ps1 b/zdeploy.ps1 index 27c2fd3..0f06f97 100644 --- a/zdeploy.ps1 +++ b/zdeploy.ps1 @@ -5,7 +5,7 @@ # zdeploy.ps1 โ€” deploy any project defined in zconfig.json to the server. # Each project runs its own docker compose stack; the handler is picked by the -# project's "kind": python | vite | nextjs | edge | docker. +# project's "kind": python | vite | nextjs | edge | docker | static. # # Usage: # zdeploy [ ...] [-Note "message"] @@ -756,6 +756,46 @@ function Invoke-EdgeDeploy { Write-Host "--- [Done] Edge proxy deploy finished ---" -ForegroundColor Green } +function Invoke-StaticDeploy { + param([string]$Key, $Proj) + + # Plain static sites - no build, no container of their own. A shared web + # container serves them straight off disk, so shipping the files IS the + # deploy: there is nothing to restart afterwards. + $root = Join-Path $Proj.localRoot $Proj.siteDir + $remotePath = $Proj.remote.path + + Write-Host "`n=== $($Proj.label) deploy (static files) ===" -ForegroundColor Cyan + if (-not (Test-Path -LiteralPath $root)) { throw "Static site root not found: $root" } + if (-not (Test-Path -LiteralPath (Join-Path $root 'index.html'))) { throw "Missing $root\index.html" } + + Invoke-Ec2Step "ensure site dir" "sudo mkdir -p $remotePath && sudo chown -R ${Ec2User}:${Ec2User} $remotePath" + + $files = @(Get-ChildItem -LiteralPath $root -File) + foreach ($f in $files) { + Write-Host " >> uploading $($f.Name)" -ForegroundColor DarkCyan + scp @SCP_OPTS -i $PEM_KEY $f.FullName "${SSH_TARGET}:$remotePath/" + if ($LASTEXITCODE -ne 0) { throw "SCP failed for $($f.Name) (exit $LASTEXITCODE)" } + } + + # A large media file uploaded in place is served half-written to anyone who + # requests it mid-copy. Ship each directory to a sibling, then swap it in - + # the swap is a rename, so the switch is atomic and visitors never see a + # partial file. + $skipDirs = @($script:JunkDirNames) + @('.github') + if ($Proj.deploy -and $Proj.deploy.skipDirs) { $skipDirs += @($Proj.deploy.skipDirs) } + $dirs = @(Get-ChildItem -LiteralPath $root -Directory | Where-Object { $skipDirs -notcontains $_.Name }) + foreach ($d in $dirs) { + Write-Host " >> uploading $($d.Name)/ (recursive, staged)" -ForegroundColor DarkCyan + Invoke-Ec2Step "stage $($d.Name)" "rm -rf $remotePath/.staging-$($d.Name) && mkdir -p $remotePath/.staging-$($d.Name)" + scp -r @SCP_OPTS -i $PEM_KEY "$($d.FullName)/*" "${SSH_TARGET}:$remotePath/.staging-$($d.Name)/" + if ($LASTEXITCODE -ne 0) { throw "SCP failed for $($d.Name) (exit $LASTEXITCODE)" } + Invoke-Ec2Step "swap in $($d.Name)" "rm -rf $remotePath/$($d.Name) && mv $remotePath/.staging-$($d.Name) $remotePath/$($d.Name)" + } + + Write-Host "--- [Done] Static site deploy finished ---" -ForegroundColor Green +} + function Invoke-DockerDeploy { param([string]$Key, $Proj) @@ -860,6 +900,7 @@ foreach ($key in $Projects) { "nextjs" { Invoke-NextDeploy -Key $key -Proj $proj -ChangeNote $Note } "edge" { Invoke-EdgeDeploy -Key $key -Proj $proj } "docker" { Invoke-DockerDeploy -Key $key -Proj $proj } + "static" { Invoke-StaticDeploy -Key $key -Proj $proj } default { throw "No deploy handler for kind '$($proj.kind)' (project '$key'). Add an Invoke-Deploy function in zdeploy.ps1." } } }