From 9d13b8ce726310ae5d9c32693a868e989072e6f0 Mon Sep 17 00:00:00 2001 From: KellyMichels Date: Tue, 28 Jul 2026 12:28:53 -0500 Subject: [PATCH] feat(zchecksums): SHA-256 manifest so a download can be verified before it's run CHECKSUMS.txt lists a SHA-256 for every top-level .ps1 and .cmd - the files a user actually executes. zchecksums verifies them; zchecksums -Update regenerates after an intentional edit. The manifest is sha256sum format, so 'sha256sum -c CHECKSUMS.txt' works on Linux/macOS/WSL as well as the PowerShell path on Windows. Hashes are identical on every platform because .gitattributes pins .ps1/.cmd to CRLF everywhere - that pin is now load-bearing, so it is commented as such. Beyond changed and missing files it also reports a script that is on disk but NOT in the manifest, so something added outside a commit still gets noticed. Exits non-zero on any of the three. Honest about its limits, in the header and the README: the manifest lives in the same repo as the code, so it is an integrity check rather than a signature. It catches a truncated clone, a forgotten local edit, or an unlisted file - not a compromised repo. CHECKSUMS.txt is pinned to LF: sha256sum treats a trailing CR as part of the filename and would report every entry as missing on Linux. tests/Checksums.Tests.ps1 keeps it from rotting - a stale manifest is worse than none, since it either cries wolf until people ignore it or quietly stops covering a new script. The tests assert the format, LF endings, sort order, full coverage of on-disk scripts, current hashes, and that zchecksums itself exits 1 on a tampered file (proved by appending a byte and restoring it). --- .gitattributes | 8 ++- CHANGELOG.md | 10 +++ CHECKSUMS.txt | 38 ++++++++++ README.md | 27 +++++++ tests/Checksums.Tests.ps1 | 111 +++++++++++++++++++++++++++++ zchecksums.cmd | 6 ++ zchecksums.ps1 | 143 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 CHECKSUMS.txt create mode 100644 tests/Checksums.Tests.ps1 create mode 100644 zchecksums.cmd create mode 100644 zchecksums.ps1 diff --git a/.gitattributes b/.gitattributes index 0a2bf76..c12806f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ -# PowerShell/cmd are happiest with CRLF on Windows. +# PowerShell/cmd are happiest with CRLF on Windows. The pin matters beyond +# convenience: it makes these files byte-identical on every platform, which is +# what lets CHECKSUMS.txt hold one hash per file rather than one per OS. *.ps1 text eol=crlf *.cmd text eol=crlf + +# The checksum manifest must stay LF: `sha256sum -c` treats a trailing CR as +# part of the filename and reports every entry as missing. +CHECKSUMS.txt text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f17d4..cb9357c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,16 @@ Notable changes to the Evomedia.net Token Savers. credentials and RSA signing keys. ### Added +- **`zchecksums` + `CHECKSUMS.txt`** — a SHA-256 manifest covering every `.ps1` + and `.cmd`, so a download can be verified before anything is run. `zchecksums` + checks them; `zchecksums -Update` regenerates after an intentional edit. The + manifest is `sha256sum` format, so `sha256sum -c CHECKSUMS.txt` works on + Linux/macOS/WSL too, and the hashes match on every platform because + `.gitattributes` pins these files to CRLF everywhere. Flags changed files, + missing files, **and scripts present on disk but absent from the manifest**. + It's an integrity check, not a signature — the manifest sits in the same repo + as the code, so it catches corruption and accidental drift, not a compromised + repo. A Pester test fails if the manifest ever goes stale. - **Test suite (Pester)** — the toolkit now has automated coverage of its own pure logic: `Get-ArchiveExcludes` (including the deploy-vs-backup rule that keeps `.env`/`uploads` out of deploys but *in* backups), config and project diff --git a/CHECKSUMS.txt b/CHECKSUMS.txt new file mode 100644 index 0000000..ef7fca8 --- /dev/null +++ b/CHECKSUMS.txt @@ -0,0 +1,38 @@ +b3452601fafaf1799be9286c41bc9badd95aad7fe45093d3239c968f4ee16e75 setup_backup_schedule.ps1 +dd39a7be920e5e9b2e99aafcc598491413b89bc902c2efd99ac9464d18f24ee1 token-count.ps1 +d05ef5941a97911781d638a0a209b10affe777b1021f89071c5414471bf6b097 zbackup.cmd +90f05dc2db328d6a8797c6d3dfc0b9cb9e1c068de7180826928bae94deda2e77 zbackup.ps1 +1fa2a6587f4d3a0b3cc0a2b2c265e1b520c7b7caf21e6cd9e110794e803375fa zbackup_and_sync.ps1 +dc6f31069607a7f60cd45399a27a88abe01695879f0b2d9272537d63b0a71d99 zbackup_ec2.cmd +5a88b72c1e15036346e9fe8e48ba83acb6465acdb35b7d94239f03263e095ccc zbackup_ec2.ps1 +c6f8da4b90d5531f245b7c55f6b4acb7e69edcc1fa2c966bc52aa8bc4f23417f zchecksums.cmd +94a96f879db64ac5f2e9f1e991c9a4a506fade5d538e3c0c7c4588124595659e zchecksums.ps1 +b2c9a2f0205768e7b301c152b1f4cf042962125d4e982812befc2e536c0f7c6b zdeploy.cmd +30bc24780b2c6772f57232eee077b3295892afbb8f8298091b11e815d8a07b23 zdeploy.ps1 +9b2702ac57115c2cd6df1f0ad33b0f8b4fd73a54dcd99475b9cacb1070a235d5 zec2.cmd +69696d75c1430ef0afe773880b962f08316c5ced4a118fe8abd71171c7e9359b zec2.ps1 +2fbd24b329f90a1b6982e3f4b8fc9068fc99e900fe718eb9ff2af7c6a98dfb4f zec2_rotatekeys.cmd +a0016e48c3b4b9b1f7e800315bbef7d2b2ca3267bf51a0ce31e41a28cb74b71b zec2_rotatekeys.ps1 +60e7ab58ca786b1a075ac268e4c2f8454bfcff47ed077cd0219f74c8d0eeaf78 zec2online.cmd +0177d8fbb6fba66a82fdde437ed9e7d43f3ef1e2227a418e9783380c549066cf zec2online.ps1 +77060d8e30cc43ce9114c58e9f2f2f76d647e3237a3357299ca1820131128636 ZHelpers.ps1 +79074a4dbd4d9fa0388cdac6ce0543a72d2b09c4779750d453c7566e500868ab zkill.cmd +d9ca58bf5baf8be8eb90fb8d414f55cd33deee35ea063dc4df7d9c9d4ed0e72c zkill.ps1 +d419dd7d407ac19b0ca83a54b622399c2343d63bfe53c1ff05e7b5b15c0c7a0f ZKiller.ps1 +ba9b2204945b19b912420290361191661a80887ecfd5d2c31d32b0686382e9a0 ZKillOnly.ps1 +462ccd1d8a8600a76885a8a62a11ba64e3123bc273815ee1c7197d2bca48d7bf zrepair.cmd +e16f4971d58ccaa9a9b0de1dedc0eb0acc126d195628831e186f2855e7895d0d zrepair.ps1 +eaa25fce9a0564df24378bedfed0815eb24c4faf9a16fdd490dafe22bcf81f4b zrestart.cmd +c1909e96e158a24f17a52053f3faeacbcbd366011bbcb1bd8a4c83d381f0355b zrestart.ps1 +ec2161b28bd9ab89fe7212a153fb948a29de6ef987b7d794ee3f8c605fd60cc6 zrestartd.cmd +48debde34be2910de9c084b155894e502f0b9447314564a6c12ffd64973c4d91 zsetup.cmd +5ec860041f07487b3322d47a874daad93dc67b1dd009204201956f3137c34205 zsetup.ps1 +b5d61fc4105fefb738653f5dfd3440d6c9126c63925e42a2d0ba2adc26afdbbc zsetup_mail.ps1 +08b943c5eb9d085274a56179f7bcb56740aefd0cfb166d45cfc65d0e6cf8ec31 zstart.cmd +e54c85132f99c6a435091758e9bbda3399a1a6833bf6ab9962362b8de62fb46b zstart.ps1 +d416fc6b781e3f130de79791c54d10b74402de0eca8582b0efcb91e4252b0d30 zstart_docker.cmd +77d498086653961c4b6febffaad889652224d46a47473273aa40acfca4916ba7 zstart_docker.ps1 +0edc7c975dfbe3cb9cd6c3c63e3e43b3b49e92a89e317283acd31a29d04d3239 zstartd.cmd +09aa986839f3a969c06ca1300365b2eb5ae0da49b27275aa1cfb9c2cf00517bf zstop.ps1 +7102ec4d459c663aeb571fa2527c069ab0d82a038e4d1ed115d107506226aa14 zsync.cmd +4e40a178eda57e986bd050f95d98ea15aa1197a9edde8df64a468c4b2948d89f zsync.ps1 diff --git a/README.md b/README.md index e700831..44b253d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ The `.cmd` wrappers are the everyday interface. Every command takes one or more | `zbackup_ec2 [ ...]` | Pull DB dumps + server-side data files down from the server | | `zsync []` | Copy new backups offsite (or build + mirror a vite dist) | | `zstart_docker` | Run a local docker compose stack from `scriptsRoot\docker\` | +| `zchecksums [-Update]` | Verify every script against `CHECKSUMS.txt` (SHA-256) | ### Local development @@ -385,6 +386,32 @@ Give the project a `verify` block instead, and `zdeploy` checks the app **from t 2. That's it: `zstart`, `zkill`, `zrestart`, `zbackup`, `zdeploy`, `zec2`, `zec2online`, `zrepair`, `zstop` all accept the new key immediately. 3. A project whose deploy doesn't fit the python/vite/nextjs/edge/docker patterns needs its own `Invoke-Deploy` function in `zdeploy.ps1` — copy an existing handler; they're all variations on zip → upload → compose up → verify. +## Verifying what you downloaded + +`CHECKSUMS.txt` holds a SHA-256 for every `.ps1` and `.cmd` in the repo. Check them before running anything: + +```powershell +zchecksums +``` + +Or with the standard tool on Linux/macOS/WSL — the manifest is `sha256sum` format: + +```bash +sha256sum -c CHECKSUMS.txt +``` + +The hashes are identical on every platform: `.gitattributes` pins `.ps1`/`.cmd` to CRLF everywhere, so a file is byte-for-byte the same whether you cloned on Windows or Linux. + +`zchecksums` flags three things — a file whose contents changed, a listed file that's gone, and a script on disk that **isn't** in the manifest (so something added quietly still gets noticed). It exits non-zero on any of them. + +If you edit a script yourself, regenerate and commit the manifest with it: + +```powershell +zchecksums -Update +``` + +**What this does and doesn't prove.** `CHECKSUMS.txt` lives in the same repo as the scripts, so anyone who could alter a script could alter the manifest too. It's an integrity check, not a signature: it reliably catches a truncated clone, a local edit you forgot about, or a file added outside a commit. It does *not* prove the code came from this project — for that you'd need a signature or a hash published outside this repo. + ## Tests The toolkit has its own [Pester](https://pester.dev) suite covering the pure logic — the exclude lists, config lookups, and version-label formatting that the deploy and backup paths depend on: diff --git a/tests/Checksums.Tests.ps1 b/tests/Checksums.Tests.ps1 new file mode 100644 index 0000000..95d7c0c --- /dev/null +++ b/tests/Checksums.Tests.ps1 @@ -0,0 +1,111 @@ +# Evomedia.net Token Savers - https://github.com/kellymichels/zscripts-token-savers +# Created by Kelly Michels - dev@evomedia.net +# Licensed under the MIT License. See LICENSE. + +# Checksums.Tests.ps1 - keeps CHECKSUMS.txt honest. +# +# Invoke-Pester .\tests +# +# A checksum manifest is worse than useless once it drifts: it either cries wolf +# on every run until people stop reading it, or it quietly stops covering a new +# script. These tests fail the moment the manifest and the scripts disagree, so +# "forgot to run zchecksums -Update" surfaces here rather than in a user's +# verification run. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:Manifest = Join-Path $script:RepoRoot "CHECKSUMS.txt" + + # Same set zchecksums.ps1 covers: top-level executables. + $script:Covered = @( + Get-ChildItem -LiteralPath $script:RepoRoot -File | + Where-Object { $_.Extension -in @('.ps1', '.cmd') } | + Sort-Object Name + ) + + $script:Listed = [ordered]@{} + if (Test-Path -LiteralPath $script:Manifest) { + foreach ($line in [IO.File]::ReadAllLines($script:Manifest)) { + if ($line -match '^([0-9a-fA-F]{64})\s+(.+)$') { + $script:Listed[$Matches[2].Trim()] = $Matches[1].ToLowerInvariant() + } + } + } +} + +Describe "CHECKSUMS.txt" { + It "exists" { + Test-Path -LiteralPath $script:Manifest | Should -BeTrue + } + + # NOTE: no angle brackets in It names - Pester treats them as -ForEach data + # placeholders and tries to evaluate the contents as an expression. + It "is sha256sum-compatible: 64 hex chars, two spaces, then the filename" { + # Anything else and `sha256sum -c CHECKSUMS.txt` warns or bails, which + # is half the point of publishing it. + foreach ($line in [IO.File]::ReadAllLines($script:Manifest)) { + if (-not $line.Trim()) { continue } + $line | Should -Match '^[0-9a-f]{64} \S.*$' + } + } + + It "uses LF line endings" { + # A trailing CR becomes part of the filename for sha256sum, so every + # entry would report as missing on Linux/macOS. + ([IO.File]::ReadAllText($script:Manifest)) | Should -Not -Match "`r" + } + + It "is sorted by filename" { + $names = @($script:Listed.Keys) + ($names -join ',') | Should -Be (($names | Sort-Object) -join ',') + } +} + +Describe "manifest matches what is on disk" { + It "lists every top-level .ps1 / .cmd (nothing silently uncovered)" { + $missingFromManifest = @($script:Covered.Name | Where-Object { -not $script:Listed.Contains($_) }) + $missingFromManifest -join ', ' | Should -BeNullOrEmpty -Because "these scripts are not in CHECKSUMS.txt - run 'zchecksums -Update'" + } + + It "lists nothing that no longer exists" { + $onDisk = @($script:Covered.Name) + $stale = @($script:Listed.Keys | Where-Object { $onDisk -notcontains $_ }) + $stale -join ', ' | Should -BeNullOrEmpty -Because "these entries point at files that are gone - run 'zchecksums -Update'" + } + + It "records the current hash of <_>" -ForEach @( + (Get-ChildItem -LiteralPath (Split-Path -Parent $PSScriptRoot) -File | + Where-Object { $_.Extension -in @('.ps1', '.cmd') } | + Sort-Object Name | Select-Object -ExpandProperty Name) + ) { + $name = $_ + $script:Listed.Contains($name) | Should -BeTrue -Because "$name is missing from CHECKSUMS.txt" + $actual = (Get-FileHash -LiteralPath (Join-Path $script:RepoRoot $name) -Algorithm SHA256).Hash.ToLowerInvariant() + $actual | Should -Be $script:Listed[$name] -Because "$name changed since CHECKSUMS.txt was written - run 'zchecksums -Update'" + } +} + +Describe "zchecksums.ps1" { + It "verifies clean and exits 0 against the committed manifest" { + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($out -join "`n") + } + + It "exits non-zero when a covered file has been tampered with" { + # Proves the check actually detects a modified script rather than always + # passing. Appends a byte to a real script, verifies, then restores it. + $victim = Join-Path $script:RepoRoot "zchecksums.cmd" + $original = [IO.File]::ReadAllBytes($victim) + try { + [IO.File]::AppendAllText($victim, "REM tampered`r`n") + & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet *> $null + $LASTEXITCODE | Should -Be 1 + } + finally { + [IO.File]::WriteAllBytes($victim, $original) + } + # Restored, so a normal verify passes again. + & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:RepoRoot "zchecksums.ps1") -Quiet *> $null + $LASTEXITCODE | Should -Be 0 + } +} diff --git a/zchecksums.cmd b/zchecksums.cmd new file mode 100644 index 0000000..13eb07a --- /dev/null +++ b/zchecksums.cmd @@ -0,0 +1,6 @@ +REM Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers +REM Created by Kelly Michels · dev@evomedia.net +REM Licensed under the MIT License. See LICENSE. + +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0zchecksums.ps1" %* diff --git a/zchecksums.ps1 b/zchecksums.ps1 new file mode 100644 index 0000000..c43c4b8 --- /dev/null +++ b/zchecksums.ps1 @@ -0,0 +1,143 @@ +# Evomedia.net Token Savers — https://github.com/kellymichels/zscripts-token-savers +# Created by Kelly Michels · dev@evomedia.net +# Licensed under the MIT License. See LICENSE. + +# zchecksums.ps1 - verify (or regenerate) SHA-256 checksums for the scripts. +# +# Usage: +# zchecksums verify every script against CHECKSUMS.txt +# zchecksums -Update regenerate CHECKSUMS.txt after changing a script +# zchecksums -Quiet verify, print only the summary line +# +# Exit codes: 0 = everything matches, 1 = a mismatch, missing or unlisted file. +# +# WHAT THIS DOES AND DOES NOT PROTECT AGAINST +# ------------------------------------------- +# CHECKSUMS.txt lives in the same repo as the scripts, so anyone able to modify +# a script can also modify the manifest. This is an integrity check, not a +# signature. It reliably catches: +# +# * a truncated or corrupted download / clone +# * a file edited locally that you forgot about +# * a script added or removed without going through a commit +# +# It does NOT prove the code came from this project - only a signature (GPG, +# Sigstore) or a hash published somewhere outside this repo can do that. Compare +# against the copy on GitHub if you need that assurance. +# +# Only *.ps1 and *.cmd are covered: they are what you actually execute, and +# .gitattributes pins them to CRLF on every platform, so their hashes are +# identical on Windows, Linux and macOS. Files without that pin would hash +# differently per platform and are deliberately left out. +# +# RUN -Update ON A CLEAN CHECKOUT +# ------------------------------ +# Hash whatever is on disk. That is only the same as what a user clones if the +# working copy matches git's normalised form. An editor that writes LF leaves a +# file git still considers unchanged (it normalises to LF in the index either +# way), so the file sits there with LF while every clone gets CRLF - and the +# manifest generated from it fails for everyone else. If in doubt: +# +# git status --short # must be clean +# git rm -r --cached . ; git reset --hard # or delete the scripts and +# # `git checkout -- .` +# +# then re-run -Update. The surest check is to clone the repo somewhere else and +# run `sha256sum -c CHECKSUMS.txt` there. + +[CmdletBinding()] +param( + [switch]$Update, + [switch]$Quiet +) + +$ErrorActionPreference = "Stop" + +$ManifestName = "CHECKSUMS.txt" +$Manifest = Join-Path $PSScriptRoot $ManifestName + +# The covered set: executable scripts, top level only. Sorted for a stable file. +function Get-CoveredFiles { + Get-ChildItem -LiteralPath $PSScriptRoot -File | + Where-Object { $_.Extension -in @('.ps1', '.cmd') } | + Sort-Object Name +} + +function Get-Sha256([string]$Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +# sha256sum-compatible: " ", LF endings, so `sha256sum -c` works on +# Linux/macOS as well as this script on Windows. +function Write-Manifest($Files) { + $lines = foreach ($f in $Files) { "{0} {1}" -f (Get-Sha256 $f.FullName), $f.Name } + $text = ($lines -join "`n") + "`n" + [IO.File]::WriteAllText($Manifest, $text, (New-Object Text.UTF8Encoding($false))) +} + +function Read-Manifest { + if (-not (Test-Path -LiteralPath $Manifest)) { return $null } + $map = [ordered]@{} + foreach ($line in [IO.File]::ReadAllLines($Manifest)) { + $t = $line.Trim() + if (-not $t -or $t.StartsWith('#')) { continue } + # "<64 hex> " - two spaces is the sha256sum convention, but accept + # any run of whitespace so a hand-edited file still parses. + if ($t -match '^([0-9a-fA-F]{64})\s+(.+)$') { + $map[$Matches[2].Trim()] = $Matches[1].ToLowerInvariant() + } + } + return $map +} + +$files = @(Get-CoveredFiles) + +if ($Update) { + Write-Manifest $files + Write-Host "" + Write-Host "=== zchecksums (updated) ===" -ForegroundColor Cyan + Write-Host (" Wrote {0} with {1} entries." -f $ManifestName, $files.Count) -ForegroundColor Green + Write-Host " Commit it alongside the script change, or verification will fail." -ForegroundColor DarkGray + Write-Host "" + exit 0 +} + +$expected = Read-Manifest +if ($null -eq $expected) { + Write-Host "ERROR: $ManifestName not found. Run 'zchecksums -Update' to create it." -ForegroundColor Red + exit 1 +} + +$ok = 0 +$changed = @() +$missing = @() +$unlisted = @() + +foreach ($f in $files) { + if (-not $expected.Contains($f.Name)) { $unlisted += $f.Name; continue } + if ((Get-Sha256 $f.FullName) -eq $expected[$f.Name]) { $ok++ } else { $changed += $f.Name } +} +foreach ($name in $expected.Keys) { + if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $name))) { $missing += $name } +} + +$bad = $changed.Count + $missing.Count + $unlisted.Count + +if (-not $Quiet) { + Write-Host "" + Write-Host "=== zchecksums ===" -ForegroundColor Cyan + foreach ($n in $changed) { Write-Host " CHANGED $n" -ForegroundColor Red } + foreach ($n in $missing) { Write-Host " MISSING $n (listed but not on disk)" -ForegroundColor Red } + foreach ($n in $unlisted) { Write-Host " UNLISTED $n (on disk but not in $ManifestName)" -ForegroundColor Yellow } +} + +if ($bad -eq 0) { + Write-Host (" OK - {0} file(s) match {1}." -f $ok, $ManifestName) -ForegroundColor Green + Write-Host "" + exit 0 +} + +Write-Host (" FAILED - {0} ok, {1} changed, {2} missing, {3} unlisted." -f $ok, $changed.Count, $missing.Count, $unlisted.Count) -ForegroundColor Red +Write-Host " If you changed a script on purpose, run: zchecksums -Update" -ForegroundColor DarkGray +Write-Host "" +exit 1