From c2405fc7cea3fed89a6773506528de7f7224c6f4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 14 Aug 2026 01:21:08 +0000 Subject: [PATCH] feat(threatcrush-scan): verify the tarball hash before installing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haven's maintainer declined the workflow and asked for "exact version + integrity hash". The version pin shipped; the hash did not. This is the half that was missing, and their framing was the correct one — the two are not the same guarantee. A version pin says which release to fetch. It does not say the bytes are the ones that release was published with, and the party answering "which version" is the party serving the bytes. Pinning alone narrows the window to a re-publish of the same version; it does not close it. So the install is now download, hash, compare, install: npm pack --pack-destination "$RUNNER_TEMP" fetch, do not install openssl dgst -sha512 | openssl base64 hash it locally compare against threatcrushIntegrity refuse on mismatch npm install -g --ignore-scripts "$tarball" install the bytes we hashed Verified npm pack reproduces the published tarball byte for byte: its sha512 equals dist.integrity for 0.11.0 exactly, so the comparison is against the registry's own published digest and not a repack of it. Two details worth keeping: Into RUNNER_TEMP, never the checkout. `npm pack` writes to the working directory by default, and a stray .tgz in the tree is something this workflow then scans and reports findings on. The mismatch is not retried, where the download is. A blip and a mismatch are different events: one is the network, the other is the registry handing back bytes nobody signed off on, and retrying that just asks again until it succeeds. threatcrushIntegrity defaults to the 0.11.0 hash and must be bumped with the spec. A hash from a different version fails closed, which is the right direction to fail but a confusing one to debug, so both the manifest and the README say so. Empty skips verification, for a consumer pointing the spec at something they build themselves. Verified all three paths against the block as written: correct hash installs, a wrong hash exits 1 without installing and prints expected and received, and an empty hash warns and proceeds. Pack to 1.3.0 — a new input. Signed-off-by: Anthony Ettinger Co-Authored-By: Claude Opus 5 --- packages/actions/threatcrush-scan/README.md | 1 + .../threatcrush-scan/sh1pt.actionpack.yaml | 21 +++++++- .../actions/threatcrush-scan/workflow.yml | 52 +++++++++++++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/actions/threatcrush-scan/README.md b/packages/actions/threatcrush-scan/README.md index 8407deb8..d6dd18b0 100644 --- a/packages/actions/threatcrush-scan/README.md +++ b/packages/actions/threatcrush-scan/README.md @@ -15,6 +15,7 @@ sh1pt actions install threatcrush-scan --repo owner/name --pr | `scanPath` | `.` | Path to scan, relative to the repository root. | | `nodeVersion` | `20` | See *Node 20, deliberately*, below. | | `threatcrushPackageSpec` | `@profullstack/threatcrush@0.11.0` | npm spec used to install the CLI. Pinned rather than `@latest` so one bad publish cannot break every consumer at once; bump it in a pack release. | +| `threatcrushIntegrity` | *(sha512 of 0.11.0)* | SRI hash of that tarball. The workflow downloads, hashes and compares before installing, and refuses to install on a mismatch. Bump it with the spec — read it from `npm view dist.integrity`. Empty skips the check. | | `failOn` | *(empty)* | Comma-separated severities that fail the job, e.g. `critical,high`. Empty is report-only. | | `uploadSarif` | `true` | Upload to the Security tab. | diff --git a/packages/actions/threatcrush-scan/sh1pt.actionpack.yaml b/packages/actions/threatcrush-scan/sh1pt.actionpack.yaml index 7f213877..4dfec37e 100644 --- a/packages/actions/threatcrush-scan/sh1pt.actionpack.yaml +++ b/packages/actions/threatcrush-scan/sh1pt.actionpack.yaml @@ -5,7 +5,7 @@ description: >- Scans pull requests for hardcoded credentials, injection, SSRF, unsafe deserialisation and dependency tampering, and uploads SARIF to the Security tab. -version: 1.2.0 +version: 1.3.0 publisher: profullstack visibility: public license: MIT @@ -40,6 +40,25 @@ inputs: is exactly what a `workspace:` protocol slip in 0.7.0/0.7.1 did. Bump this deliberately, in a pack release, so the fleet re-syncs consumers to a version that was checked first. + threatcrushIntegrity: + type: string + default: 'sha512-EKcaxsgiydi7qCH0FhvNviKUpyVi/CImwNS6Kx3IbWMuUjUPCXISAUIzFnYo8BiC+jG9dxfFDMBlwZdhqhwWfQ==' + description: >- + Subresource-integrity hash of the tarball named by threatcrushPackageSpec, + in npm's own `sha512-` form. The workflow downloads, hashes and + compares before installing, and refuses to install on a mismatch. + + A version pin says which release to fetch. It does not say the bytes are + the ones that release was published with, and the party answering the + first question is the party serving the bytes. This is the other half, + and it is what Haven's maintainer asked for when they declined on + "exact version + integrity hash". + + Must be bumped together with threatcrushPackageSpec — a hash from a + different version fails closed, which is the correct direction to fail + but a confusing one to debug. Read it from + `npm view dist.integrity`. Empty skips verification, for a + consumer pointing the spec at something they build themselves. failOn: type: string default: '' diff --git a/packages/actions/threatcrush-scan/workflow.yml b/packages/actions/threatcrush-scan/workflow.yml index 2d49fbb0..c5e1708e 100644 --- a/packages/actions/threatcrush-scan/workflow.yml +++ b/packages/actions/threatcrush-scan/workflow.yml @@ -39,18 +39,60 @@ jobs: # install hook of its own, and `scan` was verified to run correctly from # an --ignore-scripts install. A security gate that opens a shell for # its own supply chain is not a gate. + # + # Downloaded, hashed, and only then installed. A pinned version says + # which release to fetch; it does not say the bytes are the ones that + # release was published with, and the party answering "which version" + # is the party serving the tarball. The hash is the half a version pin + # cannot give you, which is the distinction Haven's maintainer drew + # when they asked for "exact version + integrity hash" rather than + # treating the pin as the answer. + # + # Into RUNNER_TEMP, never the checkout: `npm pack` writes to the working + # directory by default, and a stray .tgz in the tree is something this + # workflow then scans and reports on. - name: Install ThreatCrush run: | + set -euo pipefail + spec='{{threatcrushPackageSpec}}' + want='{{threatcrushIntegrity}}' + + name="" for attempt in 1 2 3; do - if npm install -g --ignore-scripts "{{threatcrushPackageSpec}}"; then - exit 0 + if name=$(npm pack --silent --pack-destination "${RUNNER_TEMP}" "${spec}" | tail -1) \ + && [ -n "${name}" ] && [ -f "${RUNNER_TEMP}/${name}" ]; then + break fi + name="" delay=$((attempt * 10)) - echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s" + echo "::warning::ThreatCrush download attempt ${attempt}/3 failed; retrying in ${delay}s" sleep "${delay}" done - echo "::error::ThreatCrush install failed after 3 attempts" - exit 1 + if [ -z "${name}" ]; then + echo "::error::ThreatCrush download failed after 3 attempts" + exit 1 + fi + tarball="${RUNNER_TEMP}/${name}" + + # Not retried, unlike the download. A blip and a mismatch are not the + # same event: one is the network, the other is the registry handing + # back bytes nobody signed off on, and retrying that just asks again + # until it succeeds. + if [ -n "${want}" ]; then + got="sha512-$(openssl dgst -sha512 -binary "${tarball}" | openssl base64 -A)" + if [ "${got}" != "${want}" ]; then + echo "::error::ThreatCrush integrity mismatch for ${spec}" + echo "::error::expected ${want}" + echo "::error::received ${got}" + echo "::error::refusing to install — this is not a transient failure" + exit 1 + fi + echo "Integrity verified for ${spec}: ${got}" + else + echo "::warning::no integrity hash pinned for ${spec}; installing unverified" + fi + + npm install -g --ignore-scripts "${tarball}" # Recorded into every run log so a release that changes the interface # shows up immediately, rather than silently scoring zero.