Skip to content

Commit 648799f

Browse files
authored
Validate the release checksum contract for independent pinning (#1099)
* Add independently pinned threat-detect installation * Replace standalone installer with release checksum contract validation
1 parent 30d4fd8 commit 648799f

6 files changed

Lines changed: 207 additions & 2 deletions

File tree

.github/workflows/publish-main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ jobs:
3838
echo "version=${VERSION}"
3939
} >> "$GITHUB_OUTPUT"
4040
41+
- name: Validate release checksum contract
42+
run: bash scripts/validate-release-checksums.sh dist
43+
4144
- name: Publish rolling main pre-release
4245
env:
4346
GH_TOKEN: ${{ github.token }}

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ jobs:
3838
cd dist
3939
sha256sum threat-detect-* > checksums.txt
4040
41+
- name: Validate release checksum contract
42+
run: bash scripts/validate-release-checksums.sh dist
43+
4144
- name: Upload release artifacts
4245
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
4346
with:
@@ -59,6 +62,9 @@ jobs:
5962
name: release-artifacts
6063
path: dist
6164

65+
- name: Validate downloaded release artifacts
66+
run: bash scripts/validate-release-checksums.sh dist
67+
6268
- name: Create GitHub release (prerelease)
6369
env:
6470
GH_TOKEN: ${{ github.token }}

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,38 @@ with `contents: write`. `release-targets.txt` is the canonical build matrix for
675675
both tagged and rolling releases. The scheduled Release Platform Parity workflow
676676
compares its asset names with the platforms supported by `gh-aw`'s installer.
677677

678+
### Checksum contract and independent pinning
679+
680+
Each release supplies `checksums.txt` with exactly one entry for every binary
681+
in `release-targets.txt`, and no other entries. Each line consists of a
682+
64-character lowercase hexadecimal SHA-256 digest, two ASCII spaces, the asset
683+
basename, and a newline. Entries have no required ordering. The digest covers
684+
the exact published binary bytes, without packaging or transformation.
685+
686+
Tagged and rolling release workflows validate the manifest against the matrix
687+
and hash every binary before publication. Tagged releases also validate after
688+
transferring artifacts into the publishing job. To run the same check locally:
689+
690+
```bash
691+
bash scripts/validate-release-checksums.sh dist
692+
```
693+
694+
This release contract supplies the inputs for
695+
[gh-aw#57792](https://github.com/github/gh-aw/issues/57792). The follow-up in
696+
gh-aw must review and commit the approved release tag and all platform digests
697+
together, emit those pins into compiled `*.lock.yml` detection jobs, and update
698+
its existing installer to verify against the emitted pin. A configured artifact
699+
mirror may change where bytes are downloaded, but must not change the expected
700+
digest. Missing pins or verification failures must prevent execution and
701+
downstream safe outputs, without falling back to an existing binary.
702+
703+
Fetching `checksums.txt` alongside the binary at runtime is corruption detection,
704+
not an independent trust root. Trust comes from separately reviewing and pinning
705+
the release digests in gh-aw before compilation. The mutable rolling `main`
706+
release is for development, not production version pinning. This repository
707+
does not implement the compiler or installer changes; existing compiled
708+
workflows are unaffected by the release validation added here.
709+
678710
Maintainers need to configure the following before the binary is consumed by `gh-aw`:
679711

680712
1. Keep Actions enabled for this private repository.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# Offline contract tests use real hashes and no engine or network access.
3+
set -euo pipefail
4+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
fixture=$(mktemp -d)
6+
trap 'rm -rf "$fixture"' EXIT
7+
mkdir "$fixture/dist"
8+
cp "$repo_root/release-targets.txt" "$fixture/targets"
9+
if command -v sha256sum >/dev/null 2>&1; then
10+
hash=(sha256sum)
11+
else
12+
hash=(shasum -a 256)
13+
fi
14+
while read -r os arch asset; do
15+
[[ -z "$os" || "$os" == \#* ]] && continue
16+
printf 'fixture for %s/%s\n' "$os" "$arch" > "$fixture/dist/$asset"
17+
done < "$fixture/targets"
18+
(cd "$fixture/dist" && "${hash[@]}" threat-detect-*) > "$fixture/valid"
19+
cp "$fixture/valid" "$fixture/dist/checksums.txt"
20+
21+
validate() {
22+
bash "$repo_root/scripts/validate-release-checksums.sh" "$fixture/dist" "$fixture/targets" > "$fixture/log" 2>&1
23+
}
24+
reject() {
25+
if validate; then
26+
printf 'FAIL: accepted %s\n' "$1" >&2
27+
exit 1
28+
fi
29+
if ! grep -q "$2" "$fixture/log"; then
30+
cat "$fixture/log" >&2
31+
printf 'FAIL: wrong diagnostic for %s\n' "$1" >&2
32+
exit 1
33+
fi
34+
}
35+
validate
36+
# The default matrix path works independently of the caller's working directory.
37+
(cd "$fixture" && bash "$repo_root/scripts/validate-release-checksums.sh" dist) > "$fixture/log"
38+
sort -r "$fixture/valid" > "$fixture/dist/checksums.txt"
39+
validate
40+
41+
: > "$fixture/dist/checksums.txt"
42+
reject 'empty manifest' 'Missing checksum asset'
43+
sed '1d' "$fixture/valid" > "$fixture/dist/checksums.txt"
44+
reject 'missing platform' 'Missing checksum asset'
45+
cat "$fixture/valid" "$fixture/valid" > "$fixture/dist/checksums.txt"
46+
reject 'duplicate platform' 'Duplicate checksum asset'
47+
sed '1s/threat-detect-/unexpected-/' "$fixture/valid" > "$fixture/dist/checksums.txt"
48+
reject 'unexpected asset' 'Unexpected checksum asset'
49+
sed '1s/^./g/' "$fixture/valid" > "$fixture/dist/checksums.txt"
50+
reject 'nonhex digest' 'Invalid checksum line'
51+
sed '1s/^.//' "$fixture/valid" > "$fixture/dist/checksums.txt"
52+
reject 'short digest' 'Invalid checksum line'
53+
tr 'abcdef' 'ABCDEF' < "$fixture/valid" > "$fixture/dist/checksums.txt"
54+
reject 'uppercase digest' 'Invalid checksum line'
55+
sed '1s/ / /' "$fixture/valid" > "$fixture/dist/checksums.txt"
56+
reject 'wrong separator' 'Invalid checksum line'
57+
sed '1s/$/ extra/' "$fixture/valid" > "$fixture/dist/checksums.txt"
58+
reject 'extra field' 'Invalid checksum line'
59+
sed '1s| | ../|' "$fixture/valid" > "$fixture/dist/checksums.txt"
60+
reject 'path traversal' 'Unexpected checksum asset'
61+
printf '%s' "$(cat "$fixture/valid")" > "$fixture/dist/checksums.txt"
62+
reject 'missing final newline' 'must end with a newline'
63+
64+
cp "$fixture/valid" "$fixture/dist/checksums.txt"
65+
: > "$fixture/targets"
66+
reject 'empty matrix' 'Release target matrix is empty'
67+
cat "$repo_root/release-targets.txt" "$repo_root/release-targets.txt" > "$fixture/targets"
68+
reject 'duplicate matrix' 'Duplicate release'
69+
printf 'linux amd64 ../binary\n' > "$fixture/targets"
70+
reject 'malformed matrix' 'Invalid release target'
71+
cp "$repo_root/release-targets.txt" "$fixture/targets"
72+
printf 'tampered\n' > "$fixture/dist/threat-detect-linux-amd64"
73+
reject 'digest mismatch' 'FAILED'
74+
printf 'fixture for linux/amd64\n' > "$fixture/dist/threat-detect-linux-amd64"
75+
mv "$fixture/dist/threat-detect-linux-arm64" "$fixture/absent"
76+
reject 'missing binary' 'threat-detect-linux-arm64'
77+
mv "$fixture/dist/checksums.txt" "$fixture/absent-checksums"
78+
reject 'missing manifest' 'checksums.txt'
79+
printf 'PASS: release checksum contract\n'
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
# Validate the release manifest against the platform matrix and actual bytes.
3+
set -euo pipefail
4+
export LC_ALL=C
5+
6+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7+
if [[ $# -lt 1 || $# -gt 2 ]]; then
8+
echo 'ERROR: Expected an artifact directory and optional target matrix. Example: bash scripts/validate-release-checksums.sh dist release-targets.txt' >&2
9+
exit 1
10+
fi
11+
artifact_dir=$1
12+
targets_file=${2:-$repo_root/release-targets.txt}
13+
14+
# Parse the matrix first, including when it is empty (NR == FNR is unsafe then).
15+
awk '
16+
function fail(message) {
17+
print "ERROR: " message > "/dev/stderr"
18+
bad = 1
19+
}
20+
FILENAME == ARGV[1] {
21+
if ($0 ~ /^[[:space:]]*(#|$)/) next
22+
if (NF != 3 || $3 !~ /^threat-detect-[a-z0-9-]+$/) {
23+
fail("Invalid release target. Expected <goos> <goarch> <asset>, for example linux amd64 threat-detect-linux-amd64.")
24+
next
25+
}
26+
if (platforms[$1 "/" $2]++) fail("Duplicate release platform: " $1 "/" $2)
27+
if (assets[$3]++) fail("Duplicate release asset: " $3)
28+
count++
29+
next
30+
}
31+
{
32+
# Match the exact sha256sum text-mode format used by release generation.
33+
if (length($1) != 64 || $1 ~ /[^0-9a-f]/ || NF != 2 || $0 != $1 " " $2) {
34+
fail("Invalid checksum line " FNR ". Expected 64 lowercase hexadecimal characters, two spaces, and a release asset name.")
35+
next
36+
}
37+
if (!($2 in assets)) fail("Unexpected checksum asset: " $2)
38+
if (seen[$2]++) fail("Duplicate checksum asset: " $2)
39+
}
40+
END {
41+
if (!count) fail("Release target matrix is empty. Expected at least one platform asset.")
42+
for (asset in assets) if (!(asset in seen)) fail("Missing checksum asset: " asset)
43+
exit bad ? 1 : 0
44+
}
45+
' "$targets_file" "$artifact_dir/checksums.txt"
46+
47+
if [[ "$(tail -c 1 "$artifact_dir/checksums.txt" | od -An -tu1 | tr -d '[:space:]')" != 10 ]]; then
48+
echo 'ERROR: Checksum manifest must end with a newline. Regenerate it with sha256sum.' >&2
49+
exit 1
50+
fi
51+
52+
# Only hash after validating the manifest filenames against the trusted matrix.
53+
if command -v sha256sum >/dev/null 2>&1; then
54+
(cd "$artifact_dir" && sha256sum --check checksums.txt)
55+
else
56+
(cd "$artifact_dir" && shasum -a 256 --check checksums.txt)
57+
fi

specs/usage-spec.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ here as `TD-XX`.
5151
## 2. Acquisition
5252

5353
**U-01**: A conforming host MUST acquire the detector as a published GitHub
54-
Release asset from `github/gh-aw-threat-detection`. The host MUST NOT build the
55-
detector from source as part of a production detection job.
54+
Release asset from `github/gh-aw-threat-detection`, either directly or through
55+
an organization-approved mirror or preinstallation of that asset. The host MUST
56+
NOT build the detector from source as part of a production detection job.
5657

5758
**U-02**: The host MUST select the release asset matching the runner operating
5859
system and architecture: `threat-detect-linux-amd64`,
@@ -63,6 +64,33 @@ system and architecture: `threat-detect-linux-amd64`,
6364
recorded for that asset (via `checksums.txt` published alongside the assets, or
6465
the sha256 recorded in the release notes) before executing it.
6566

67+
**U-03a**: A host requiring independently pinned artifacts MUST obtain the
68+
expected SHA-256 from trusted configuration reviewed separately from runtime
69+
acquisition (for example, compiler-embedded digests or a checksum file committed
70+
with the consuming workflow). Downloading both the binary and its checksum from
71+
the release or mirror during installation does not satisfy this requirement.
72+
Pins MUST be associated with an explicit release tag and platform asset name.
73+
Missing, malformed, duplicate, or mismatched pins MUST fail installation before
74+
execution, with no fallback to an unverified or existing binary. An approved
75+
mirror changes the source of the bytes, not the expected digest. A preinstalled
76+
asset MUST also be verified against the independent pin before execution.
77+
78+
**U-03b**: A detector release MUST publish `checksums.txt` with exactly one
79+
entry for each binary asset in the release source revision's `release-targets.txt`
80+
and no additional entries. Each line MUST contain a 64-character lowercase
81+
hexadecimal SHA-256 digest, two ASCII spaces, the asset basename, and a newline.
82+
Entry order is not significant. Each digest MUST match the exact published
83+
binary bytes. Release publication MUST fail if the manifest is malformed,
84+
contains missing, duplicate, or unexpected entries, or does not match the
85+
binary assets. This applies to both tagged and rolling releases; a rolling
86+
release still MUST NOT be used as a production version pin (U-04).
87+
88+
The release manifest supplies inputs for the host's independent pinning process;
89+
it is not itself an independent trust root. A host such as gh-aw must separately
90+
review and pin the release tag and platform digests, then embed them into its
91+
compiled workflows. Neither release validation nor publication implements that
92+
host integration.
93+
6694
**U-04**: The host MUST pin acquisition to an explicit release tag (per TD-25,
6795
U-24, and U-26). A host that resolves the latest promoted (stable) release MUST
6896
first resolve it to a concrete release tag and then download that pinned tag; it

0 commit comments

Comments
 (0)