Skip to content

Commit 96443dc

Browse files
feat(release): enforce supply-chain evidence
1 parent 5728244 commit 96443dc

19 files changed

Lines changed: 1097 additions & 162 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Releases now publish verifiable SBOM, license, provenance, signature, and vulnerability evidence for every supported production image and platform. Production NATS, Traefik, nginx, and Alpine images are pinned to reviewed digests and covered by the same release evidence and recurring scans as Hephaestus images.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Setup release security tools
2+
description: Install checksum-verified Syft and Trivy binaries used by release evidence workflows
3+
inputs:
4+
install-syft:
5+
description: Install Syft in addition to Trivy
6+
default: "true"
7+
runs:
8+
using: composite
9+
steps:
10+
- shell: bash
11+
env:
12+
INSTALL_SYFT: ${{ inputs.install-syft }}
13+
# renovate: datasource=github-releases depName=anchore/syft
14+
SYFT_VERSION: 1.51.1
15+
# renovate: datasource=github-releases depName=aquasecurity/trivy
16+
TRIVY_VERSION: 0.74.0
17+
run: |
18+
set -euo pipefail
19+
if [ "$INSTALL_SYFT" = true ]; then
20+
syft_archive="syft_${SYFT_VERSION}_linux_amd64.tar.gz"
21+
syft_base="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}"
22+
curl -fsSL --proto =https -o "/tmp/$syft_archive" "$syft_base/$syft_archive"
23+
curl -fsSL --proto =https -o /tmp/syft-checksums "$syft_base/syft_${SYFT_VERSION}_checksums.txt"
24+
(cd /tmp && grep " $syft_archive$" syft-checksums | sha256sum -c -)
25+
tar -xzf "/tmp/$syft_archive" -C /usr/local/bin syft
26+
syft version
27+
fi
28+
trivy_archive="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
29+
trivy_base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}"
30+
curl -fsSL --proto =https -o "/tmp/$trivy_archive" "$trivy_base/$trivy_archive"
31+
curl -fsSL --proto =https -o /tmp/trivy-checksums "$trivy_base/trivy_${TRIVY_VERSION}_checksums.txt"
32+
(cd /tmp && grep " $trivy_archive$" trivy-checksums | sha256sum -c -)
33+
tar -xzf "/tmp/$trivy_archive" -C /usr/local/bin trivy
34+
trivy --version

.github/workflows/release.yml

Lines changed: 259 additions & 64 deletions
Large diffs are not rendered by default.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Rescan supported release images
2+
on:
3+
schedule:
4+
- cron: "17 4 * * 1"
5+
workflow_dispatch:
6+
permissions:
7+
contents: read
8+
packages: read
9+
issues: write
10+
concurrency:
11+
group: rescan-supported-release
12+
cancel-in-progress: false
13+
jobs:
14+
rescan:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 30
17+
steps:
18+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
19+
with:
20+
persist-credentials: false
21+
- uses: ./.github/actions/setup-bun
22+
- uses: ./.github/actions/setup-release-security-tools
23+
with:
24+
install-syft: "false"
25+
- name: Download and verify latest supported release evidence
26+
env:
27+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
run: |
29+
set -euo pipefail
30+
mkdir release-evidence
31+
gh release download --repo "${{ github.repository }}" --dir release-evidence
32+
(cd release-evidence && sha256sum -c SHA256SUMS)
33+
for file in manifest.json release-images.json tool-versions.json trivy-db.json vulnerability-policy.json; do
34+
test -s "release-evidence/$file"
35+
done
36+
jq -e --slurpfile inventory release-evidence/release-images.json \
37+
'.schemaVersion == 1 and
38+
([.subjects[].image] | unique == (($inventory[0].images + [$inventory[0].upstream[].name]) | sort)) and
39+
(group_by(.image) | all(.[]; [.[].platform] == ["linux/amd64", "linux/arm64"])) and
40+
all(.subjects[]; (.digest | test("^sha256:[a-f0-9]{64}$")) and (.indexDigest | test("^sha256:[a-f0-9]{64}$")))' \
41+
release-evidence/manifest.json >/dev/null
42+
jq -er '.subjects[] | [.image, .platform, .indexDigest, .digest, .repository] | @tsv' release-evidence/manifest.json |
43+
while IFS=$'\t' read -r image platform index_digest digest repository; do
44+
suffix=${platform//\//-}
45+
bun scripts/check-release-sbom.ts \
46+
"release-evidence/$image-$suffix.syft.json" \
47+
"release-evidence/$image-$suffix.spdx.json" \
48+
"release-evidence/$image-$suffix.cdx.json" \
49+
"$digest" "$platform" /tmp/sbom-summary.json
50+
cmp "release-evidence/$image-$suffix.sbom-summary.json" /tmp/sbom-summary.json
51+
for kind in trivy policy; do
52+
test -s "release-evidence/$image-$suffix.$kind.json"
53+
done
54+
architecture=${platform#*/}
55+
docker buildx imagetools inspect "$repository@$index_digest" --raw |
56+
jq -e --arg architecture "$architecture" --arg digest "$digest" \
57+
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture and .digest == $digest)' >/dev/null
58+
done
59+
- name: Rescan immutable subjects
60+
env:
61+
TRIVY_USERNAME: ${{ github.actor }}
62+
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
63+
run: |
64+
set -euo pipefail
65+
mkdir reports
66+
trivy image --download-db-only
67+
db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json"
68+
jq -e '(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated |
69+
((now - $updated) >= 0) and ((now - $updated) <= 86400)' \
70+
"$db_metadata" >/dev/null
71+
cp "$db_metadata" reports/trivy-db.json
72+
jq -er '.subjects[] | [.image, .platform, .digest, .repository] | @tsv' release-evidence/manifest.json |
73+
while IFS=$'\t' read -r image platform digest repository; do
74+
suffix=${platform//\//-}
75+
trivy image --scanners vuln --format json --output "reports/$image-$suffix.json" "$repository@$digest"
76+
bun scripts/check-release-vulnerabilities.ts "$image" "reports/$image-$suffix.json" security/vulnerability-policy.json "reports/$image-$suffix.policy.json"
77+
done
78+
- name: Upload diagnostic reports
79+
if: always()
80+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
81+
with:
82+
name: supported-release-rescan-${{ github.run_id }}
83+
path: reports
84+
if-no-files-found: warn
85+
retention-days: 30
86+
- name: Notify vulnerability response tracking
87+
if: failure()
88+
env:
89+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90+
run: |
91+
gh issue comment 1369 --repo "${{ github.repository }}" --body \
92+
"The supported-release rescan is **unknown or failing policy**. Treat this as actionable until triaged: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

.github/workflows/reusable-docker-build.yml

Lines changed: 0 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ jobs:
7171
permissions:
7272
contents: read
7373
packages: write
74-
security-events: write
7574
id-token: write
7675
attestations: write
7776
steps:
@@ -228,89 +227,6 @@ jobs:
228227
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
229228
echo "Per-arch digest: $DIGEST"
230229
231-
# SBOMs are emitted by Paketo into /layers/sbom/ during the build; pack downloads them via
232-
# the OCI label io.buildpacks.lifecycle.metadata.
233-
- name: Install pack CLI (Buildpacks SBOM extraction)
234-
if: inputs.use-buildpacks
235-
env:
236-
PACK_VERSION: v0.40.6
237-
run: |
238-
set -euo pipefail
239-
ARCH_SUFFIX=$([ "${{ runner.arch }}" = "ARM64" ] && echo "-arm64" || echo "")
240-
BASE="https://github.com/buildpacks/pack/releases/download/${PACK_VERSION}"
241-
curl -fsSL --proto =https -o /tmp/pack.tgz "${BASE}/pack-${PACK_VERSION}-linux${ARCH_SUFFIX}.tgz"
242-
curl -fsSL --proto =https -o /tmp/pack.sha256 "${BASE}/pack-${PACK_VERSION}-linux${ARCH_SUFFIX}.tgz.sha256"
243-
( cd /tmp && awk '{print $1" pack.tgz"}' pack.sha256 | sha256sum -c - )
244-
tar -xzf /tmp/pack.tgz -C /usr/local/bin pack
245-
pack version
246-
247-
- name: Extract SBOM from buildpack image
248-
if: inputs.use-buildpacks
249-
continue-on-error: true
250-
env:
251-
PER_ARCH_TAG: ${{ inputs.registry }}/${{ inputs.image-name }}:ci-${{ github.run_id }}-${{ steps.prep.outputs.platform_pair }}
252-
run: |
253-
set -euo pipefail
254-
mkdir -p "${{ runner.temp }}/sbom"
255-
# --remote reads from the registry rather than the local daemon — the image was just
256-
# pushed by spring-boot:build-image. Auth is inherited from the docker/login-action
257-
# earlier in this job via ~/.docker/config.json.
258-
pack sbom download "$PER_ARCH_TAG" --remote --output-dir "${{ runner.temp }}/sbom"
259-
ls -la "${{ runner.temp }}/sbom/layers/sbom/"
260-
261-
- name: Upload SBOM
262-
if: inputs.use-buildpacks
263-
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
264-
with:
265-
name: sbom-${{ steps.prep.outputs.image_name }}-${{ steps.prep.outputs.platform_pair }}
266-
path: ${{ runner.temp }}/sbom/
267-
if-no-files-found: error
268-
retention-days: 90
269-
270-
# Direct trivy binary install — aquasecurity/trivy-action's transitive setup-trivy dep
271-
# breaks consumers of this reusable workflow when it can't resolve. Report-only baseline;
272-
# flip --exit-code 1 once HIGH+ is at zero on main.
273-
- name: Install Trivy
274-
id: install-trivy
275-
if: inputs.use-buildpacks
276-
continue-on-error: true
277-
env:
278-
TRIVY_VERSION: 0.70.0
279-
run: |
280-
set -euo pipefail
281-
ARCH=$([ "${{ runner.arch }}" = "ARM64" ] && echo "ARM64" || echo "64bit")
282-
BASE="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}"
283-
ARCHIVE="trivy_${TRIVY_VERSION}_Linux-${ARCH}.tar.gz"
284-
curl -fsSL --proto =https -o "/tmp/${ARCHIVE}" "${BASE}/${ARCHIVE}"
285-
curl -fsSL --proto =https -o /tmp/trivy_checksums.txt "${BASE}/trivy_${TRIVY_VERSION}_checksums.txt"
286-
( cd /tmp && grep " ${ARCHIVE}$" trivy_checksums.txt | sha256sum -c - )
287-
tar -xzf "/tmp/${ARCHIVE}" -C /usr/local/bin trivy
288-
trivy --version
289-
290-
- name: Run Trivy (CVE scan, report-only)
291-
if: inputs.use-buildpacks && steps.install-trivy.outcome == 'success'
292-
continue-on-error: true
293-
env:
294-
# Trivy queries the registry directly (not the local daemon) and does not read
295-
# ~/.docker/config.json — explicit credentials are required for ghcr.io.
296-
TRIVY_USERNAME: ${{ github.actor }}
297-
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
298-
PER_ARCH_TAG: ${{ inputs.registry }}/${{ inputs.image-name }}:ci-${{ github.run_id }}-${{ steps.prep.outputs.platform_pair }}
299-
run: |
300-
set -euo pipefail
301-
trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 0 \
302-
--format sarif --output "${{ runner.temp }}/trivy.sarif" "$PER_ARCH_TAG"
303-
304-
# Surface findings in the GitHub Security tab + PR annotations rather than a 30-day artifact
305-
# nobody opens. continue-on-error keeps the workflow green while the baseline is non-zero.
306-
- name: Upload Trivy SARIF to GitHub code scanning
307-
if: inputs.use-buildpacks && steps.install-trivy.outcome == 'success'
308-
continue-on-error: true
309-
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.30.9
310-
with:
311-
sarif_file: ${{ runner.temp }}/trivy.sarif
312-
category: trivy-image-${{ steps.prep.outputs.platform_pair }}
313-
314230
- name: Export digest
315231
env:
316232
DOCKERFILE_DIGEST: ${{ steps.build.outputs.digest }}

docker/compose.core.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ services:
131131
max-file: "3"
132132

133133
nats-server:
134-
image: nats:alpine
134+
image: nats:2.14.6-alpine@sha256:ad7a43eb7e3337c3c38ce5d784d1461791f95f730f252d2b25eee699752a0ca3
135135
restart: unless-stopped
136136
ports:
137137
# Loopback-only by default. Set NATS_BIND_HOST=0.0.0.0 (or a specific interface) to let other

docker/compose.proxy.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ services:
22
reverse-proxy:
33
# Keep at >=3.3: below it the app-server sticky.cookie.path label (workspace replica affinity)
44
# fails to parse and Traefik silently drops the whole router. Pin a current 3.x.
5-
image: traefik:v3.7.4
5+
image: traefik:v3.7.4@sha256:fcdef599e6259359833dd2e1d49f9e964f66825d69bd3dd468f51102ce013d03
66
restart: unless-stopped
77
networks:
88
- shared-network
@@ -60,7 +60,7 @@ services:
6060
max-file: "3"
6161

6262
maintenance:
63-
image: nginx:alpine
63+
image: nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913
6464
restart: unless-stopped
6565
configs:
6666
- source: maintenance-page
@@ -202,4 +202,4 @@ configs:
202202
<p>&mdash; Your Hephaestus Administrators</p>
203203
</div>
204204
</article>
205-
</html>
205+
</html>

docs/admin/buildpacks-cds-decision.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,6 @@ Revert `.github/workflows/ci-docker-build.yml` (the `use-buildpacks: true` line)
6464

6565
- **Coolify graceful shutdown**`application.yml` sets `SHUTDOWN_TIMEOUT:20s`. Coolify's default container stop-grace is 10s; bump it to ≥25s in the deploy substrate so SIGTERM has time to drain in-flight requests. The Paketo launcher `exec`s the JVM; signal forwarding is native, no `tini`.
6666
- **JVM memory** — do NOT set `MaxRAMPercentage`, `-Xmx`, or `-Xss` in Coolify env. Paketo's memory calculator handles them. Override only `BPL_JVM_HEAD_ROOM` if needed.
67-
- **SBOM** — Paketo emits Syft + SPDX + CycloneDX at `/layers/sbom/`. CI extracts via `pack sbom download` and uploads as a 90-day artifact.
68-
- **CVE scan** — Trivy runs on every PR and uploads SARIF to GitHub Security. Until the baseline is clean, results are non-blocking; flip `--exit-code 1` in the workflow once HIGH+ is at zero.
6967
- **CI build time** — expect +60–120s per build vs the prior Dockerfile baseline (CDS training run dominates).
7068

7169
## Sources

docs/contributor/release-management.mdx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ sequenceDiagram
3232
M->>VP: changesets action folds pending changesets into the accumulating Version PR
3333
Note over VP: CHANGELOG.md + version bump preview
3434
VP->>M: Maintainer merges when the release is ready
35-
M->>R: Tag vX.Y.Z, GitHub Release with curated notes,<br/>docker images X.Y.Z / X.Y / latest
35+
M->>R: Create draft vX.Y.Z release
36+
R->>R: Generate and verify digest-bound evidence
37+
R->>R: Promote X.Y.Z / X.Y / latest image tags
38+
R->>R: Publish release and deploy staging
39+
Note over R: Production still requires approval
3640
```
3741

3842
1. **Every user-facing PR carries a changeset.** `bun changeset` asks for the bump type and an
@@ -50,10 +54,73 @@ sequenceDiagram
5054
the full suite and a release is only cut if that run succeeds. Maintainers bypass the branch
5155
ruleset, so the absent required checks don't block the merge. Versioning also moves any
5256
`### Next release` section in `MIGRATION.md` under the version being cut.
53-
3. **Merging the Version PR cuts the release.** The release workflow tags `vX.Y.Z` at the merge
54-
commit, creates the GitHub Release from the new changelog section, retags the CI-built Docker
55-
images as `X.Y.Z`, `X.Y`, and `latest`, publishes the signed release-pin asset, and starts the
56-
deploy chain (staging automatically, production after approval).
57+
3. **Merging the Version PR cuts the release.** The workflow creates a draft release at the merge
58+
commit. After its evidence gate passes, it promotes the CI-built images to `X.Y.Z`, `X.Y`, and
59+
`latest`, publishes the release, deploys staging, and requests production approval.
60+
61+
## Supply-chain evidence
62+
63+
A release remains a draft until every first-party and upstream production image in
64+
`security/release-images.json` has passed the evidence
65+
gate. The gate resolves each image index and its `linux/amd64` and `linux/arm64` manifests to immutable
66+
digests. It generates a lossless Syft inventory plus SPDX and CycloneDX SBOMs for each deployed platform.
67+
The gate proves that the Syft source metadata identifies the exact digest and architecture, checks that
68+
every discovered package survives both standard-format conversions, and records packages whose license
69+
could not be detected. It also scans each platform manifest with Trivy, signs its SPDX predicate as an OCI
70+
attestation, and verifies the image signature and build provenance. The SBOMs, license summaries, scan
71+
reports, applied policy, checksums, and `manifest.json` are GitHub Release assets, so they outlive Actions
72+
artifact retention. Publication requires every artifact to be present, well formed, and bound to its
73+
recorded digest.
74+
75+
Upstream NATS, Traefik, nginx, and Alpine images are versioned and digest-pinned in the production
76+
Compose topology. They receive the same per-platform SBOM, license, vulnerability, and index-membership
77+
evidence as first-party images. Hephaestus requires its own signatures and build provenance only for
78+
images it builds; it does not misrepresent observed upstream images as Hephaestus-built artifacts.
79+
80+
The version-controlled policy rejects new fixable HIGH or CRITICAL findings. Baselines use exact
81+
image/CVE/package/installed-version fingerprints. Exceptions use the same scope and require an owner,
82+
justification, and future expiry.
83+
84+
The latest release is rescanned weekly. A policy violation or scan failure is reported on
85+
[the vulnerability response issue](https://github.com/ls1intum/Hephaestus/issues/1369); scanner failure
86+
is never treated as no findings.
87+
88+
### Operator verification
89+
90+
Install `gh`, `jq`, `cosign`, and GNU `sha256sum`, then use a clean directory and replace `vX.Y.Z`
91+
below. Verification uses the digests in the release manifest, never mutable tags:
92+
93+
```bash
94+
gh release download vX.Y.Z --repo ls1intum/Hephaestus
95+
sha256sum -c SHA256SUMS
96+
97+
jq -r '.subjects[] | [.image, .platform, .digest] | @tsv' manifest.json |
98+
while IFS=$'\t' read -r image platform digest; do
99+
ref="ghcr.io/ls1intum/hephaestus/$image@$digest"
100+
suffix=${platform//\//-}
101+
cosign verify-attestation --type spdxjson \
102+
--certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/release.yml@refs/heads/main' \
103+
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$ref" \
104+
> verified-attestations.json
105+
jq -e --slurpfile sbom "$image-$suffix.spdx.json" \
106+
'any(.[]; (.payload | @base64d | fromjson | .predicate) == $sbom[0])' \
107+
verified-attestations.json >/dev/null
108+
done
109+
110+
jq -r '[.subjects[] | [.image, .indexDigest]] | unique[] | @tsv' manifest.json |
111+
while IFS=$'\t' read -r image digest; do
112+
ref="ghcr.io/ls1intum/hephaestus/$image@$digest"
113+
cosign verify "$ref" \
114+
--certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/reusable-docker-build.yml@refs/heads/main' \
115+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
116+
gh attestation verify "oci://$ref" --owner ls1intum \
117+
--signer-workflow ls1intum/Hephaestus/.github/workflows/reusable-docker-build.yml
118+
done
119+
```
120+
121+
The downloaded `*.spdx.json` and `*.cdx.json` files are the machine-readable package and license
122+
inventories. `*.trivy.json` is a point-in-time scan, not a promise that vulnerability knowledge remains
123+
unchanged.
57124

58125
## Writing changesets
59126

0 commit comments

Comments
 (0)