Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bind-release-security-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hephaestus": patch
---

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.
22 changes: 22 additions & 0 deletions .github/workflows/ci-docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ on:
description: "Whether an application-server image exists at this commit"
value: ${{ jobs.application-server-build.outputs.manifest-digest != '' || jobs.tag-unchanged-images.outputs.application-server-published == 'true' }}

permissions: {}

jobs:
webapp-build:
name: "Webapp"
if: inputs.should_skip != 'true' && inputs.webapp_changed == 'true'
uses: ./.github/workflows/reusable-docker-build.yml
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
image-name: "ls1intum/hephaestus/webapp"
docker-file: "./webapp/Dockerfile"
Expand Down Expand Up @@ -74,6 +81,11 @@ jobs:
inputs.should_skip != 'true' &&
(inputs.application_server_changed == 'true' || github.event_name != 'pull_request')
uses: ./.github/workflows/reusable-docker-build.yml
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
image-name: "ls1intum/hephaestus/application-server"
# Paketo Cloud Native Buildpacks via spring-boot:build-image, with Application CDS.
Expand Down Expand Up @@ -107,6 +119,11 @@ jobs:
name: "Agent: Pi"
if: inputs.should_skip != 'true' && inputs.agent_images_changed == 'true'
uses: ./.github/workflows/reusable-docker-build.yml
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
image-name: "ls1intum/hephaestus/agent-pi"
docker-file: "./docker/agents/pi/Dockerfile"
Expand Down Expand Up @@ -136,6 +153,11 @@ jobs:
inputs.should_skip != 'true' &&
(inputs.postgres_image_changed == 'true' || github.event_name != 'pull_request')
uses: ./.github/workflows/reusable-docker-build.yml
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
image-name: "ls1intum/hephaestus/postgres"
docker-file: "./docker/postgres/Dockerfile"
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions: {}

jobs:
detect-changes:
name: "Detect changes"
Expand Down Expand Up @@ -199,6 +201,9 @@ jobs:
github.event_name != 'pull_request'
)
secrets: inherit
permissions:
checks: write
contents: read
with:
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
Expand All @@ -215,6 +220,9 @@ jobs:
github.event_name != 'pull_request'
)
secrets: inherit
permissions:
contents: read
security-events: write
with:
should_skip: ${{ needs.detect-changes.outputs.should_skip }}

Expand All @@ -228,6 +236,11 @@ jobs:
github.event_name != 'pull_request'
)
secrets: inherit
permissions:
contents: read
checks: write
pull-requests: write
statuses: write
with:
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
webapp_changed: ${{ (needs.detect-changes.outputs.webapp == 'true' || needs.detect-changes.outputs.ci-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
Expand All @@ -237,6 +250,8 @@ jobs:
Changesets:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/verify-changesets.yml
permissions:
contents: read

Docker:
uses: ./.github/workflows/ci-docker-build.yml
Expand All @@ -255,6 +270,11 @@ jobs:
needs.detect-changes.outputs.ci-config == 'true'
)
secrets: inherit
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
should_skip: ${{ needs.detect-changes.outputs.should_skip }}
webapp_changed: ${{ (needs.detect-changes.outputs.webapp-image == 'true' || needs.detect-changes.outputs.docker-config == 'true' || github.event_name != 'pull_request') && 'true' || 'false' }}
Expand Down
21 changes: 17 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,15 @@ jobs:
"evidence/$image-$suffix.syft.json" \
"evidence/$image-$suffix.spdx.json" \
"evidence/$image-$suffix.cdx.json" \
"$platform_digest" "$platform" "evidence/$image-$suffix.sbom-summary.json"
"$platform_digest" "$platform" "evidence/$image-$suffix.sbom-validation.json"
trivy image --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref"
bun scripts/check-release-vulnerabilities.ts "$image" "evidence/$image-$suffix.trivy.json" security/vulnerability-policy.json "evidence/$image-$suffix.policy.json"
trivy image --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref"
jq -e --arg ref "$platform_ref" \
'.ArtifactName == $ref and (.Results | type == "array")' \
"evidence/$image-$suffix.license.json" >/dev/null
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$platform_digest" "$repository" \
"evidence/$image-$suffix.trivy.json" security/vulnerability-policy.json \
"evidence/$image-$suffix.policy.json"
if [ "$provenance" = first-party ]; then
cosign attest --yes --type spdxjson --predicate "evidence/$image-$suffix.spdx.json" "$platform_ref"
fi
Expand Down Expand Up @@ -463,11 +469,18 @@ jobs:
"evidence/$image-$suffix.syft.json" \
"evidence/$image-$suffix.spdx.json" \
"evidence/$image-$suffix.cdx.json" \
"$digest" "$platform" /tmp/sbom-summary.json
cmp "evidence/$image-$suffix.sbom-summary.json" /tmp/sbom-summary.json
"$digest" "$platform" /tmp/sbom-validation.json
cmp "evidence/$image-$suffix.sbom-validation.json" /tmp/sbom-validation.json
for kind in trivy policy; do
test -s "evidence/$image-$suffix.$kind.json"
done
jq -e --arg ref "$repository@$digest" \
'.ArtifactName == $ref and (.Results | type == "array")' \
"evidence/$image-$suffix.license.json" >/dev/null
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
"evidence/$image-$suffix.trivy.json" evidence/vulnerability-policy.json \
/tmp/policy.json
cmp "evidence/$image-$suffix.policy.json" /tmp/policy.json
if [ "$provenance" = first-party ]; then
cosign verify-attestation --type spdxjson \
--certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \
Expand Down
28 changes: 24 additions & 4 deletions .github/workflows/rescan-release-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ jobs:
- uses: ./.github/actions/setup-release-security-tools
with:
install-syft: "false"
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Download and verify latest supported release evidence
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag=$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)
mkdir release-evidence
gh release download --repo "${{ github.repository }}" --dir release-evidence
gh release download "$tag" --repo "${{ github.repository }}" --dir release-evidence
(cd release-evidence && sha256sum -c SHA256SUMS)
for file in manifest.json release-images.json tool-versions.json trivy-db.json vulnerability-policy.json; do
test -s "release-evidence/$file"
Expand All @@ -46,16 +49,31 @@ jobs:
"release-evidence/$image-$suffix.syft.json" \
"release-evidence/$image-$suffix.spdx.json" \
"release-evidence/$image-$suffix.cdx.json" \
"$digest" "$platform" /tmp/sbom-summary.json
cmp "release-evidence/$image-$suffix.sbom-summary.json" /tmp/sbom-summary.json
"$digest" "$platform" /tmp/sbom-validation.json
cmp "release-evidence/$image-$suffix.sbom-validation.json" /tmp/sbom-validation.json
for kind in trivy policy; do
test -s "release-evidence/$image-$suffix.$kind.json"
done
jq -e --arg ref "$repository@$digest" \
'.ArtifactName == $ref and (.Results | type == "array")' \
"release-evidence/$image-$suffix.license.json" >/dev/null
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
"release-evidence/$image-$suffix.trivy.json" \
release-evidence/vulnerability-policy.json /tmp/policy.json
cmp "release-evidence/$image-$suffix.policy.json" /tmp/policy.json
architecture=${platform#*/}
docker buildx imagetools inspect "$repository@$index_digest" --raw |
jq -e --arg architecture "$architecture" --arg digest "$digest" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture and .digest == $digest)' >/dev/null
done
lock="release-$tag.json"
cosign verify-blob \
--bundle "release-evidence/$lock.sigstore.json" \
--certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/release.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"release-evidence/$lock"
bun scripts/release-image-lock.ts "release-evidence/$lock" \
release-evidence/manifest.json "$tag" /tmp/release-lock.env
- name: Rescan immutable subjects
env:
TRIVY_USERNAME: ${{ github.actor }}
Expand All @@ -73,7 +91,9 @@ jobs:
while IFS=$'\t' read -r image platform digest repository; do
suffix=${platform//\//-}
trivy image --scanners vuln --format json --output "reports/$image-$suffix.json" "$repository@$digest"
bun scripts/check-release-vulnerabilities.ts "$image" "reports/$image-$suffix.json" security/vulnerability-policy.json "reports/$image-$suffix.policy.json"
bun scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \
"reports/$image-$suffix.json" security/vulnerability-policy.json \
"reports/$image-$suffix.policy.json"
done
- name: Upload diagnostic reports
if: always()
Expand Down
9 changes: 5 additions & 4 deletions docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ digests. It generates a lossless Syft inventory plus SPDX and CycloneDX SBOMs fo
The gate proves that the Syft source metadata identifies the exact digest and architecture, checks that
every discovered package survives both standard-format conversions, and records packages whose license
could not be detected. It also scans each platform manifest with Trivy, signs its SPDX predicate as an OCI
attestation, and verifies the image signature and build provenance. The SBOMs, license summaries, scan
attestation, and verifies the image signature and build provenance. The SBOMs, license reports, scan
reports, applied policy, checksums, and `manifest.json` are GitHub Release assets, so they outlive Actions
artifact retention. Publication requires every artifact to be present, well formed, and bound to its
recorded digest.
Expand All @@ -77,9 +77,10 @@ Compose topology. They receive the same per-platform SBOM, license, vulnerabilit
evidence as first-party images. Hephaestus requires its own signatures and build provenance only for
images it builds; it does not misrepresent observed upstream images as Hephaestus-built artifacts.

The version-controlled policy rejects new fixable HIGH or CRITICAL findings. Baselines use exact
image/CVE/package/installed-version fingerprints. Exceptions use the same scope and require an owner,
justification, and future expiry.
The version-controlled policy rejects every HIGH or CRITICAL finding. A residual finding requires an
exception scoped to its image, vulnerability, package, and installed version, with an owner, justification,
evidence URL, and expiry no more than 90 days away. Each exception and policy report records the scanned
platform and digest.

The latest release is rescanned weekly. A policy violation or scan failure is reported on
[the vulnerability response issue](https://github.com/ls1intum/Hephaestus/issues/1369); scanner failure
Expand Down
89 changes: 72 additions & 17 deletions scripts/check-release-vulnerabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,61 +10,87 @@ const finding = {
VulnerabilityID: "CVE-1",
};
const report = { Results: [{ Vulnerabilities: [finding] }] };
const policy = { baseline: [], exceptions: [], schemaVersion: 1 };
const policy = { exceptions: [], schemaVersion: 2 };
const digest = `sha256:${"a".repeat(64)}`;
const subject = { digest, platform: "linux/amd64", reference: `registry.example/server@${digest}` };
const now = new Date("2026-09-01T00:00:00Z");

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

await test("accepts an explicitly baselined installed version", () => {
assert.deepEqual(
evaluate("server", report, { ...policy, baseline: ["server|CVE-1|lib|1"] }).rejected,
[],
);
});

await test("does not classify an unfixed vulnerability as actionable", () => {
await test("requires a disposition even when no fixed version exists", () => {
assert.deepEqual(
evaluate(
"server",
{ Results: [{ Vulnerabilities: [{ ...finding, FixedVersion: "" }] }] },
policy,
).rejected,
[],
["server|CVE-1|lib|1"],
);
});

await test("accepts an owned, justified, unexpired exception", () => {
const exceptions = [
{
expires: "2099-01-01T00:00:00Z",
digest,
evidence: "https://github.com/example/project/issues/1",
expires: "2026-10-01T00:00:00Z",
image: "server",
installedVersion: "1",
justification: "not reachable in the deployed configuration",
owner: "@security",
package: "lib",
platform: "linux/amd64",
status: "not_affected",
vulnerability: "CVE-1",
},
];
assert.deepEqual(evaluate("server", report, { ...policy, exceptions }).rejected, []);
assert.deepEqual(
evaluate("server", report, {
...policy,
exceptions: [{ ...exceptions[0], installedVersion: "0" }],
}).rejected,
evaluate(
"server",
{ ...report, ArtifactName: subject.reference },
{ ...policy, exceptions },
now,
subject,
).rejected,
[],
);
assert.deepEqual(
evaluate(
"server",
{ ...report, ArtifactName: subject.reference },
{ ...policy, exceptions: [{ ...exceptions[0], installedVersion: "0" }] },
now,
subject,
).rejected,
["server|CVE-1|lib|1"],
);
assert.deepEqual(
evaluate(
"server",
{ ...report, ArtifactName: subject.reference },
{ ...policy, exceptions: [{ ...exceptions[0], digest: `sha256:${"b".repeat(64)}` }] },
now,
subject,
).rejected,
["server|CVE-1|lib|1"],
);
});

await test("rejects expired and malformed exceptions", () => {
const exceptions = [
{
digest,
evidence: "https://github.com/example/project/issues/1",
expires: "2020-01-01T00:00:00Z",
image: "server",
installedVersion: "1",
justification: "reviewed",
owner: "@security",
package: "lib",
platform: "linux/amd64",
status: "affected",
vulnerability: "CVE-1",
},
];
Expand All @@ -77,4 +103,33 @@ await test("rejects expired and malformed exceptions", () => {
() => evaluate("server", { Results: [{ Vulnerabilities: [{ Severity: "HIGH" }] }] }, policy),
/missing InstalledVersion/,
);
assert.match(
evaluate("server", report, {
...policy,
exceptions: [exceptions[0], exceptions[0]],
}).errors.join("\n"),
/duplicate exception/,
);
assert.match(
evaluate(
"server",
report,
{
...policy,
exceptions: [{ ...exceptions[0], expires: "2027-01-01T00:00:00Z" }],
},
now,
).errors.join("\n"),
/90-day limit/,
);
assert.match(
evaluate(
"server",
{ ...report, ArtifactName: "registry.example/server@sha256:wrong" },
policy,
new Date(),
subject,
).errors.join("\n"),
/Trivy report is for/,
);
});
Loading
Loading