Skip to content

Commit 83c6092

Browse files
author
Test
committed
fix(security): bind release evidence to immutable image digests
1 parent f2f39a7 commit 83c6092

10 files changed

Lines changed: 230 additions & 62 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 reject high and critical image vulnerabilities unless a disposition is bound to the exact image digest and platform, owned, evidence-backed, and expires within 90 days. Clean verification and recurring rescans authenticate and re-evaluate the same signed release lock.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,18 @@ on:
3636
description: "Whether an application-server image exists at this commit"
3737
value: ${{ jobs.application-server-build.outputs.manifest-digest != '' || jobs.tag-unchanged-images.outputs.application-server-published == 'true' }}
3838

39+
permissions: {}
40+
3941
jobs:
4042
webapp-build:
4143
name: "Webapp"
4244
if: inputs.should_skip != 'true' && inputs.webapp_changed == 'true'
4345
uses: ./.github/workflows/reusable-docker-build.yml
46+
permissions:
47+
contents: read
48+
packages: write
49+
id-token: write
50+
attestations: write
4451
with:
4552
image-name: "ls1intum/hephaestus/webapp"
4653
docker-file: "./webapp/Dockerfile"
@@ -74,6 +81,11 @@ jobs:
7481
inputs.should_skip != 'true' &&
7582
(inputs.application_server_changed == 'true' || github.event_name != 'pull_request')
7683
uses: ./.github/workflows/reusable-docker-build.yml
84+
permissions:
85+
contents: read
86+
packages: write
87+
id-token: write
88+
attestations: write
7789
with:
7890
image-name: "ls1intum/hephaestus/application-server"
7991
# Paketo Cloud Native Buildpacks via spring-boot:build-image, with Application CDS.
@@ -107,6 +119,11 @@ jobs:
107119
name: "Agent: Pi"
108120
if: inputs.should_skip != 'true' && inputs.agent_images_changed == 'true'
109121
uses: ./.github/workflows/reusable-docker-build.yml
122+
permissions:
123+
contents: read
124+
packages: write
125+
id-token: write
126+
attestations: write
110127
with:
111128
image-name: "ls1intum/hephaestus/agent-pi"
112129
docker-file: "./docker/agents/pi/Dockerfile"
@@ -136,6 +153,11 @@ jobs:
136153
inputs.should_skip != 'true' &&
137154
(inputs.postgres_image_changed == 'true' || github.event_name != 'pull_request')
138155
uses: ./.github/workflows/reusable-docker-build.yml
156+
permissions:
157+
contents: read
158+
packages: write
159+
id-token: write
160+
attestations: write
139161
with:
140162
image-name: "ls1intum/hephaestus/postgres"
141163
docker-file: "./docker/postgres/Dockerfile"

.github/workflows/cicd.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ concurrency:
1111
group: ${{ github.workflow }}-${{ github.ref }}
1212
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
1313

14+
permissions: {}
15+
1416
jobs:
1517
detect-changes:
1618
name: "Detect changes"
@@ -174,6 +176,9 @@ jobs:
174176
github.event_name != 'pull_request'
175177
)
176178
secrets: inherit
179+
permissions:
180+
checks: write
181+
contents: read
177182
with:
178183
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
179184
webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
@@ -190,6 +195,9 @@ jobs:
190195
github.event_name != 'pull_request'
191196
)
192197
secrets: inherit
198+
permissions:
199+
contents: read
200+
security-events: write
193201
with:
194202
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
195203

@@ -203,6 +211,11 @@ jobs:
203211
github.event_name != 'pull_request'
204212
)
205213
secrets: inherit
214+
permissions:
215+
contents: read
216+
checks: write
217+
pull-requests: write
218+
statuses: write
206219
with:
207220
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
208221
webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
@@ -212,6 +225,8 @@ jobs:
212225
Changesets:
213226
if: github.event_name == 'pull_request'
214227
uses: ./.github/workflows/verify-changesets.yml
228+
permissions:
229+
contents: read
215230

216231
Docker:
217232
uses: ./.github/workflows/ci-docker-build.yml
@@ -230,6 +245,11 @@ jobs:
230245
needs.detect-changes.outputs.ci-config == 'true'
231246
)
232247
secrets: inherit
248+
permissions:
249+
contents: read
250+
packages: write
251+
id-token: write
252+
attestations: write
233253
with:
234254
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
235255
webapp_changed: ${{ (needs.detect-changes.outputs.webapp-image == 'true' || needs.detect-changes.outputs.docker-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}

.github/workflows/release.yml

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,15 @@ jobs:
274274
"evidence/$image-$suffix.syft.json" \
275275
"evidence/$image-$suffix.spdx.json" \
276276
"evidence/$image-$suffix.cdx.json" \
277-
"$platform_digest" "$platform" "evidence/$image-$suffix.sbom-summary.json"
277+
"$platform_digest" "$platform" "evidence/$image-$suffix.sbom-validation.json"
278278
trivy image --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref"
279-
bun scripts/check-release-vulnerabilities.ts "$image" "evidence/$image-$suffix.trivy.json" security/vulnerability-policy.json "evidence/$image-$suffix.policy.json"
279+
trivy image --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref"
280+
jq -e --arg ref "$platform_ref" \
281+
'.ArtifactName == $ref and (.Results | type == "array")' \
282+
"evidence/$image-$suffix.license.json" >/dev/null
283+
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$platform_digest" "$repository" \
284+
"evidence/$image-$suffix.trivy.json" security/vulnerability-policy.json \
285+
"evidence/$image-$suffix.policy.json"
280286
if [ "$provenance" = first-party ]; then
281287
cosign attest --yes --type spdxjson --predicate "evidence/$image-$suffix.spdx.json" "$platform_ref"
282288
fi
@@ -463,11 +469,18 @@ jobs:
463469
"evidence/$image-$suffix.syft.json" \
464470
"evidence/$image-$suffix.spdx.json" \
465471
"evidence/$image-$suffix.cdx.json" \
466-
"$digest" "$platform" /tmp/sbom-summary.json
467-
cmp "evidence/$image-$suffix.sbom-summary.json" /tmp/sbom-summary.json
472+
"$digest" "$platform" /tmp/sbom-validation.json
473+
cmp "evidence/$image-$suffix.sbom-validation.json" /tmp/sbom-validation.json
468474
for kind in trivy policy; do
469475
test -s "evidence/$image-$suffix.$kind.json"
470476
done
477+
jq -e --arg ref "$repository@$digest" \
478+
'.ArtifactName == $ref and (.Results | type == "array")' \
479+
"evidence/$image-$suffix.license.json" >/dev/null
480+
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
481+
"evidence/$image-$suffix.trivy.json" evidence/vulnerability-policy.json \
482+
/tmp/policy.json
483+
cmp "evidence/$image-$suffix.policy.json" /tmp/policy.json
471484
if [ "$provenance" = first-party ]; then
472485
cosign verify-attestation --type spdxjson \
473486
--certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \

.github/workflows/rescan-release-images.yml

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ jobs:
2222
- uses: ./.github/actions/setup-release-security-tools
2323
with:
2424
install-syft: "false"
25+
- name: Install cosign
26+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
2527
- name: Download and verify latest supported release evidence
2628
env:
2729
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2830
run: |
2931
set -euo pipefail
32+
tag=$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)
3033
mkdir release-evidence
31-
gh release download --repo "${{ github.repository }}" --dir release-evidence
34+
gh release download "$tag" --repo "${{ github.repository }}" --dir release-evidence
3235
(cd release-evidence && sha256sum -c SHA256SUMS)
3336
for file in manifest.json release-images.json tool-versions.json trivy-db.json vulnerability-policy.json; do
3437
test -s "release-evidence/$file"
@@ -46,16 +49,31 @@ jobs:
4649
"release-evidence/$image-$suffix.syft.json" \
4750
"release-evidence/$image-$suffix.spdx.json" \
4851
"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
52+
"$digest" "$platform" /tmp/sbom-validation.json
53+
cmp "release-evidence/$image-$suffix.sbom-validation.json" /tmp/sbom-validation.json
5154
for kind in trivy policy; do
5255
test -s "release-evidence/$image-$suffix.$kind.json"
5356
done
57+
jq -e --arg ref "$repository@$digest" \
58+
'.ArtifactName == $ref and (.Results | type == "array")' \
59+
"release-evidence/$image-$suffix.license.json" >/dev/null
60+
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
61+
"release-evidence/$image-$suffix.trivy.json" \
62+
release-evidence/vulnerability-policy.json /tmp/policy.json
63+
cmp "release-evidence/$image-$suffix.policy.json" /tmp/policy.json
5464
architecture=${platform#*/}
5565
docker buildx imagetools inspect "$repository@$index_digest" --raw |
5666
jq -e --arg architecture "$architecture" --arg digest "$digest" \
5767
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture and .digest == $digest)' >/dev/null
5868
done
69+
lock="release-$tag.json"
70+
cosign verify-blob \
71+
--bundle "release-evidence/$lock.sigstore.json" \
72+
--certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/release.yml@refs/heads/main' \
73+
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
74+
"release-evidence/$lock"
75+
bun scripts/release-image-lock.ts "release-evidence/$lock" \
76+
release-evidence/manifest.json "$tag" /tmp/release-lock.env
5977
- name: Rescan immutable subjects
6078
env:
6179
TRIVY_USERNAME: ${{ github.actor }}
@@ -73,7 +91,9 @@ jobs:
7391
while IFS=$'\t' read -r image platform digest repository; do
7492
suffix=${platform//\//-}
7593
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"
94+
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
95+
"reports/$image-$suffix.json" security/vulnerability-policy.json \
96+
"reports/$image-$suffix.policy.json"
7797
done
7898
- name: Upload diagnostic reports
7999
if: always()

docs/contributor/release-management.mdx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ digests. It generates a lossless Syft inventory plus SPDX and CycloneDX SBOMs fo
6767
The gate proves that the Syft source metadata identifies the exact digest and architecture, checks that
6868
every discovered package survives both standard-format conversions, and records packages whose license
6969
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
70+
attestation, and verifies the image signature and build provenance. The SBOMs, license reports, scan
7171
reports, applied policy, checksums, and `manifest.json` are GitHub Release assets, so they outlive Actions
7272
artifact retention. Publication requires every artifact to be present, well formed, and bound to its
7373
recorded digest.
@@ -77,9 +77,10 @@ Compose topology. They receive the same per-platform SBOM, license, vulnerabilit
7777
evidence as first-party images. Hephaestus requires its own signatures and build provenance only for
7878
images it builds; it does not misrepresent observed upstream images as Hephaestus-built artifacts.
7979

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.
80+
The version-controlled policy rejects every HIGH or CRITICAL finding. A residual finding requires an
81+
exception scoped to its image, vulnerability, package, and installed version, with an owner, justification,
82+
evidence URL, and expiry no more than 90 days away. Each exception and policy report records the scanned
83+
platform and digest.
8384

8485
The latest release is rescanned weekly. A policy violation or scan failure is reported on
8586
[the vulnerability response issue](https://github.com/ls1intum/Hephaestus/issues/1369); scanner failure

scripts/check-release-vulnerabilities.test.ts

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,61 +10,87 @@ const finding = {
1010
VulnerabilityID: "CVE-1",
1111
};
1212
const report = { Results: [{ Vulnerabilities: [finding] }] };
13-
const policy = { baseline: [], exceptions: [], schemaVersion: 1 };
13+
const policy = { exceptions: [], schemaVersion: 2 };
14+
const digest = `sha256:${"a".repeat(64)}`;
15+
const subject = { digest, platform: "linux/amd64", reference: `registry.example/server@${digest}` };
16+
const now = new Date("2026-09-01T00:00:00Z");
1417

15-
await test("rejects a new actionable fixed vulnerability", () => {
18+
await test("rejects a high vulnerability without a disposition", () => {
1619
assert.deepEqual(evaluate("server", report, policy).rejected, ["server|CVE-1|lib|1"]);
1720
});
1821

19-
await test("accepts an explicitly baselined installed version", () => {
20-
assert.deepEqual(
21-
evaluate("server", report, { ...policy, baseline: ["server|CVE-1|lib|1"] }).rejected,
22-
[],
23-
);
24-
});
25-
26-
await test("does not classify an unfixed vulnerability as actionable", () => {
22+
await test("requires a disposition even when no fixed version exists", () => {
2723
assert.deepEqual(
2824
evaluate(
2925
"server",
3026
{ Results: [{ Vulnerabilities: [{ ...finding, FixedVersion: "" }] }] },
3127
policy,
3228
).rejected,
33-
[],
29+
["server|CVE-1|lib|1"],
3430
);
3531
});
3632

3733
await test("accepts an owned, justified, unexpired exception", () => {
3834
const exceptions = [
3935
{
40-
expires: "2099-01-01T00:00:00Z",
36+
digest,
37+
evidence: "https://github.com/example/project/issues/1",
38+
expires: "2026-10-01T00:00:00Z",
4139
image: "server",
4240
installedVersion: "1",
4341
justification: "not reachable in the deployed configuration",
4442
owner: "@security",
4543
package: "lib",
44+
platform: "linux/amd64",
45+
status: "not_affected",
4646
vulnerability: "CVE-1",
4747
},
4848
];
49-
assert.deepEqual(evaluate("server", report, { ...policy, exceptions }).rejected, []);
5049
assert.deepEqual(
51-
evaluate("server", report, {
52-
...policy,
53-
exceptions: [{ ...exceptions[0], installedVersion: "0" }],
54-
}).rejected,
50+
evaluate(
51+
"server",
52+
{ ...report, ArtifactName: subject.reference },
53+
{ ...policy, exceptions },
54+
now,
55+
subject,
56+
).rejected,
57+
[],
58+
);
59+
assert.deepEqual(
60+
evaluate(
61+
"server",
62+
{ ...report, ArtifactName: subject.reference },
63+
{ ...policy, exceptions: [{ ...exceptions[0], installedVersion: "0" }] },
64+
now,
65+
subject,
66+
).rejected,
67+
["server|CVE-1|lib|1"],
68+
);
69+
assert.deepEqual(
70+
evaluate(
71+
"server",
72+
{ ...report, ArtifactName: subject.reference },
73+
{ ...policy, exceptions: [{ ...exceptions[0], digest: `sha256:${"b".repeat(64)}` }] },
74+
now,
75+
subject,
76+
).rejected,
5577
["server|CVE-1|lib|1"],
5678
);
5779
});
5880

5981
await test("rejects expired and malformed exceptions", () => {
6082
const exceptions = [
6183
{
84+
digest,
85+
evidence: "https://github.com/example/project/issues/1",
6286
expires: "2020-01-01T00:00:00Z",
6387
image: "server",
6488
installedVersion: "1",
6589
justification: "reviewed",
6690
owner: "@security",
6791
package: "lib",
92+
platform: "linux/amd64",
93+
status: "affected",
6894
vulnerability: "CVE-1",
6995
},
7096
];
@@ -77,4 +103,33 @@ await test("rejects expired and malformed exceptions", () => {
77103
() => evaluate("server", { Results: [{ Vulnerabilities: [{ Severity: "HIGH" }] }] }, policy),
78104
/missing InstalledVersion/,
79105
);
106+
assert.match(
107+
evaluate("server", report, {
108+
...policy,
109+
exceptions: [exceptions[0], exceptions[0]],
110+
}).errors.join("\n"),
111+
/duplicate exception/,
112+
);
113+
assert.match(
114+
evaluate(
115+
"server",
116+
report,
117+
{
118+
...policy,
119+
exceptions: [{ ...exceptions[0], expires: "2027-01-01T00:00:00Z" }],
120+
},
121+
now,
122+
).errors.join("\n"),
123+
/90-day limit/,
124+
);
125+
assert.match(
126+
evaluate(
127+
"server",
128+
{ ...report, ArtifactName: "registry.example/server@sha256:wrong" },
129+
policy,
130+
new Date(),
131+
subject,
132+
).errors.join("\n"),
133+
/Trivy report is for/,
134+
);
80135
});

0 commit comments

Comments
 (0)