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.