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
114 changes: 114 additions & 0 deletions .github/workflows/reusable-docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,117 @@ jobs:
--certificate-github-workflow-repository "${{ github.repository }}" \
> /dev/null
gh attestation verify "oci://$IMAGE_REF" --owner "${{ github.repository_owner }}"

# The release vulnerability gate, run where the image is built. Until this job existed the
# policy's first execution was at `tag-images`, so a release was the first thing it could
# fail — a tripwire, not a safety net (issue #1703). It calls the same script and the same
# security/vulnerability-policy.json the release and the scheduled rescan call; a second
# copy of the policy is precisely the failure this prevents.
#
# linux/amd64 only: Alpine and Debian ship the same package versions across architectures,
# and the release still scans both, where the evidence bundle has to be complete.
scan:
name: Scan linux/amd64 Image
needs: [build, merge]
# `merge` is skipped for single-architecture builds, which would skip this job too
# without a status-check function to suppress the implicit success() over every need.
if: >-
${{ !cancelled() && needs.build.result == 'success' &&
needs.merge.result != 'failure' && needs.merge.result != 'cancelled' }}
timeout-minutes: 20
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false

- uses: ./.github/actions/setup-node-pnpm
with:
install: "none"

- uses: ./.github/actions/setup-release-security-tools
with:
install-syft: "false"

- name: Log in to Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ inputs.registry }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Resolve the linux/amd64 subject
id: subject
env:
# The run tag both build paths already publish; it names this run's image whether
# `build` pushed it directly or `merge` assembled an index from the per-arch pushes.
IMAGE_REF: ${{ inputs.registry }}/${{ inputs.image-name }}:run-${{ github.run_id }}-${{ github.run_attempt }}
INPUT_IMAGE_NAME: ${{ inputs.image-name }}
run: |
set -euo pipefail
# First, so the artifact below is still named after the image if the lookup fails.
echo "image=${INPUT_IMAGE_NAME##*/}" >> "$GITHUB_OUTPUT"
raw=$(docker buildx imagetools inspect "$IMAGE_REF" --raw)
if jq -e 'has("manifests")' <<< "$raw" > /dev/null; then
digest=$(jq -er 'first(.manifests[]
| select(.platform.os == "linux" and .platform.architecture == "amd64")
| .digest)' <<< "$raw")
else
digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$IMAGE_REF")
fi
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::No linux/amd64 digest for $IMAGE_REF (got: ${digest:-<empty>})"
exit 1
fi
echo "digest=$digest" >> "$GITHUB_OUTPUT"

- name: Download the Trivy database
env:
# A blocking gate inherits Trivy's database availability as a hard dependency, and
# GHCR answers TOOMANYREQUESTS often enough that Harbor, Rancher and New Relic all
# override this. Trivy walks the list in order. The `:2` tag is the database schema
# version and is pinned to the Trivy version in setup-release-security-tools; a
# schema bump fails this step loudly rather than silently fetching the wrong DB.
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2
run: |
set -euo pipefail
for attempt in 1 2 3; do
trivy image --timeout 90s --download-db-only && break
if [ "$attempt" -eq 3 ]; then
echo "::error::Trivy database download failed after $attempt attempts"
exit 1
fi
sleep $((attempt * 5))
done

- name: Enforce the release vulnerability policy
env:
IMAGE: ${{ steps.subject.outputs.image }}
REPOSITORY: ${{ inputs.registry }}/${{ inputs.image-name }}
SUBJECT_DIGEST: ${{ steps.subject.outputs.digest }}
TRIVY_USERNAME: ${{ github.actor }}
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p reports
trivy image --skip-db-update --scanners vuln --format json \
--output "reports/$IMAGE-linux-amd64.json" "$REPOSITORY@$SUBJECT_DIGEST"
node scripts/check-release-vulnerabilities.ts "$IMAGE" linux/amd64 "$SUBJECT_DIGEST" \
"$REPOSITORY" "reports/$IMAGE-linux-amd64.json" security/vulnerability-policy.json \
"reports/$IMAGE-linux-amd64.policy.json"

# The policy result names the CVEs; without it the gate says only that the image failed,
# and diagnosing v0.75.0 meant rebuilding and rescanning the base image by hand.
- name: Upload the vulnerability policy result
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vulnerability-policy-${{ steps.subject.outputs.image }}-linux-amd64
path: reports
if-no-files-found: warn
retention-days: 7
1 change: 1 addition & 0 deletions docs/contributor/ci-cd.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ why the label is the authority, and which alternatives were priced and declined,
| `ci-quality-gates.yml` | Called by cicd.yml | Code quality, formatting, schema validation |
| `ci-tests.yml` | Called by cicd.yml | Unit, integration, visual tests |
| `ci-docker-build.yml` | Called by cicd.yml | Docker image builds per component |
| `reusable-docker-build.yml` | Called by ci-docker-build.yml | Builds, signs, attests, and blocks on the release vulnerability policy for the `linux/amd64` image |
| `ci-security-scan.yml` | Called by cicd.yml | Dependency scanning (Trivy), secret detection |
| `ci-profile.yml` | Weekly, manual | Profiles server integration tests and Spring contexts |
| `ci-server-clean-reference.yml` | Weekly, manual | Records cold server phases and compares generated JARs |
Expand Down
18 changes: 14 additions & 4 deletions docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,20 @@ 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 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 version-controlled policy rejects every HIGH or CRITICAL finding **for which upstream has published
a fix**. An unfixable finding is still counted and still appears in the signed evidence, but it does not
block: the pinned upstream images cannot be patched at all, so failing on one removes no risk and leaves
nobody an action. A residual fixable finding requires an exception scoped to its image, platform,
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 digest, but an exception is
not matched on it — the release digest does not exist until the images are tagged, so a digest-keyed
exception could only ever be written after the gate had already failed a release.

The same policy runs on every image build, against the `linux/amd64` image the run just pushed, so a
pull request fails before a release can. The release scans both architectures, where the evidence bundle
has to be complete. Both call `scripts/check-release-vulnerabilities.ts` with
`security/vulnerability-policy.json`; there is no second copy of either. A failing gate names the
rejected CVEs in the run summary and uploads its `.policy.json`.

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
134 changes: 121 additions & 13 deletions scripts/check-release-vulnerabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { evaluate } from "./check-release-vulnerabilities.ts";
import { evaluate, renderSummary } from "./check-release-vulnerabilities.ts";

const finding = {
FixedVersion: "2",
Expand All @@ -19,14 +19,53 @@ await test("rejects a high vulnerability without a disposition", () => {
assert.deepEqual(evaluate("server", report, policy).rejected, ["server|CVE-1|lib|1"]);
});

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("reports but does not reject a high vulnerability with no upstream fix", () => {
// The four upstream images in security/release-images.json cannot be patched at all, so
// an unfixable finding blocks a release without offering anyone an action. It still has
// to be counted, because the evidence bundle is signed and must stay complete.
for (const unfixable of [
{ ...finding, FixedVersion: "" },
{ ...finding, FixedVersion: " " },
Object.fromEntries(Object.entries(finding).filter(([field]) => field !== "FixedVersion")),
]) {
const result = evaluate("server", { Results: [{ Vulnerabilities: [unfixable] }] }, policy);
assert.deepEqual(result.rejected, []);
assert.deepEqual(result.highCritical, ["server|CVE-1|lib|1"]);
assert.deepEqual(result.errors, []);
}
// A mixed report rejects only the half upstream has published a fix for.
const mixed = evaluate(
"server",
{
Results: [
{
Vulnerabilities: [
finding,
{ ...finding, PkgName: "other", VulnerabilityID: "CVE-2", FixedVersion: "" },
{ ...finding, PkgName: "third", VulnerabilityID: "CVE-3", Severity: "CRITICAL" },
],
},
],
},
policy,
);
assert.deepEqual(mixed.rejected, ["server|CVE-1|lib|1", "server|CVE-3|third|1"]);
assert.deepEqual(mixed.highCritical, [
"server|CVE-1|lib|1",
"server|CVE-2|other|1",
"server|CVE-3|third|1",
]);
});

await test("rejects a report whose fixed version is not a string", () => {
assert.throws(
() =>
evaluate(
"server",
{ Results: [{ Vulnerabilities: [{ ...finding, FixedVersion: 2 }] }] },
policy,
),
/FixedVersion must be a string/,
);
});

Expand Down Expand Up @@ -66,15 +105,56 @@ await test("accepts an owned, justified, unexpired exception", () => {
).rejected,
["server|CVE-1|lib|1"],
);
assert.deepEqual(
});

await test("matches an exception without its digest, but still on every other field", () => {
const exception = {
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",
};
const rejectedWith = (override: Partial<typeof exception>): string[] =>
evaluate(
"server",
{ ...report, ArtifactName: subject.reference },
{ ...policy, exceptions: [{ ...exceptions[0], digest: `sha256:${"b".repeat(64)}` }] },
{ ...policy, exceptions: [{ ...exception, ...override }] },
now,
subject,
).rejected,
["server|CVE-1|lib|1"],
).rejected;
// The release digest does not exist until the images are tagged, so matching on it made a
// pre-release exception impossible to author. An exception reviewed against one digest now
// covers the same package at the same version wherever it is scanned.
assert.deepEqual(rejectedWith({ digest: `sha256:${"b".repeat(64)}` }), []);
// Every other field still binds.
for (const override of [
{ image: "other" },
{ platform: "linux/arm64" },
{ package: "other" },
{ installedVersion: "0" },
{ vulnerability: "CVE-2" },
] satisfies Partial<typeof exception>[])
assert.deepEqual(rejectedWith(override), ["server|CVE-1|lib|1"], JSON.stringify(override));
// Two exceptions that differ only by digest are now one exception, twice.
assert.match(
evaluate(
"server",
{ ...report, ArtifactName: subject.reference },
{
...policy,
exceptions: [exception, { ...exception, digest: `sha256:${"b".repeat(64)}` }],
},
now,
subject,
).errors.join("\n"),
/duplicate exception: server\|linux\/amd64\|CVE-1\|lib\|1/,
);
});

Expand Down Expand Up @@ -133,3 +213,31 @@ await test("rejects expired and malformed exceptions", () => {
/Trivy report is for/,
);
});

await test("names the rejected findings in the run summary", () => {
const rendered = renderSummary(
{ digest, image: "server", platform: "linux/amd64" },
evaluate(
"server",
{
Results: [
{
Vulnerabilities: [finding, { ...finding, VulnerabilityID: "CVE-2", FixedVersion: "" }],
},
],
},
policy,
),
);
assert.match(rendered, /server \(linux\/amd64\): fail/);
assert.match(rendered, /2 HIGH\/CRITICAL, 1 rejected/);
assert.match(rendered, /\| CVE-1 \| lib \| 1 \|/);
assert.doesNotMatch(rendered, /\| CVE-2 \|/);

const clean = renderSummary(
{ digest, image: "server", platform: "linux/amd64" },
evaluate("server", { Results: [] }, policy),
);
assert.match(clean, /server \(linux\/amd64\): pass/);
assert.doesNotMatch(clean, /\| Vulnerability \|/);
});
Loading
Loading