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
54 changes: 54 additions & 0 deletions .github/actions/download-trivy-db/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Download the Trivy database
description: >-
Fetches the Trivy vulnerability database once, with the retries and registry fallback a gate
needs, and optionally asserts its freshness and records its metadata.
inputs:
max-age-hours:
description: >-
Fail when the downloaded database is older than this many whole hours. Empty skips the
assertion; evidence-producing scans set it so a stale mirror cannot be signed as a scan.
required: false
default: ""
metadata-path:
description: >-
Copy the database metadata to this path, creating parent directories. Empty skips the copy.
required: false
default: ""
runs:
using: composite
steps:
- name: Download the Trivy database
shell: bash
env:
MAX_AGE_HOURS: ${{ inputs.max-age-hours }}
METADATA_PATH: ${{ inputs.metadata-path }}
# 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, pinned to the Trivy version in setup-release-security-tools, so a schema bump
# fails loudly rather than silently fetching the wrong database.
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2
run: |
set -euo pipefail
if [ -n "$MAX_AGE_HOURS" ] && ! [[ "$MAX_AGE_HOURS" =~ ^[0-9]+$ ]]; then
echo "::error::max-age-hours must be a whole number of hours"
exit 1
fi
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
db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json"
if [ -n "$MAX_AGE_HOURS" ]; then
jq -e --argjson max "$((MAX_AGE_HOURS * 3600))" \
'(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated |
((now - $updated) >= 0) and ((now - $updated) <= $max)' \
"$db_metadata" >/dev/null
fi
if [ -n "$METADATA_PATH" ]; then
mkdir -p "$(dirname "$METADATA_PATH")"
cp "$db_metadata" "$METADATA_PATH"
fi
20 changes: 7 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ jobs:
with:
install: "none"

# The database the whole evidence bundle is scanned against: recorded into the bundle, and
# refused if it is over a day old, so a stale mirror cannot be signed as a fresh scan.
- uses: ./.github/actions/download-trivy-db
with:
max-age-hours: "24"
metadata-path: evidence/trivy-db.json

- name: Generate and enforce release evidence
env:
TRIVY_USERNAME: ${{ github.actor }}
Expand All @@ -227,19 +234,6 @@ jobs:
mkdir -p evidence
: > release-platforms.tsv
started=$SECONDS
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
db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json"
jq -e '(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated |
((now - $updated) >= 0) and ((now - $updated) <= 86400)' \
"$db_metadata" >/dev/null
cp "$db_metadata" evidence/trivy-db.json
jq -n --arg syft "$(syft version -o json | jq -r .version)" \
--arg trivy "$(trivy version --format json | jq -r .Version)" \
--arg cosign "$(cosign version --json | jq -r .gitVersion)" \
Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/rescan-main-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Rescans the images built from main's HEAD against the release vulnerability policy.
#
# The gate in reusable-docker-build.yml runs where an image is built, so it catches a change that
# introduces a finding and cannot catch a finding that appears with no change at all. That is the
# common case: v0.75.0 was blocked by CVEs published after the code merged clean, and nothing
# noticed until the release failed. rescan-release-images.yml already covers published releases on
# this cadence; this covers what main would release today.
#
# Non-blocking by design. Findings go to a tracking issue, updated in place, because a red status on
# a schedule nobody triggered — for a regression no commit caused and no revert fixes — is how a team
# learns to ignore the gate.
name: Rescan main images

on:
schedule:
# Weekly, twenty minutes after the supported-release rescan, so the two never contend for the
# Trivy database. Monday also puts findings in front of the same review Renovate's PRs land for.
- cron: "37 4 * * 1"
workflow_dispatch:

permissions:
contents: read
packages: read

concurrency:
group: rescan-main-images
cancel-in-progress: false

jobs:
rescan:
name: Rescan main's images
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
issues: write
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: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# A day old at most: an older mirror would report last week's drift as this week's.
- uses: ./.github/actions/download-trivy-db
with:
max-age-hours: "24"
metadata-path: reports/trivy-db.json

# Resolves each image's `:main` tag to its linux/amd64 digest, scans it, and evaluates the one
# security/vulnerability-policy.json through the one scripts/check-release-vulnerabilities.ts.
# A finding does not fail this step; an unreachable registry or a broken scanner does.
- name: Scan the images built from main
env:
TRIVY_USERNAME: ${{ github.actor }}
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/scan-main-images.ts reports

- name: Upload the vulnerability policy results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: main-image-rescan-${{ github.run_id }}
path: reports
if-no-files-found: warn
retention-days: 30

- name: Route findings to the tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/report-vulnerability-drift.ts reports
18 changes: 4 additions & 14 deletions .github/workflows/rescan-release-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,16 @@ jobs:
node scripts/release-image-lock.ts "release-evidence/$lock" \
release-evidence/manifest.json "$tag" /tmp/release-lock.env
node scripts/verify-release-evidence.ts release-evidence
- uses: ./.github/actions/download-trivy-db
with:
max-age-hours: "24"
metadata-path: reports/trivy-db.json
- name: Rescan immutable subjects
env:
TRIVY_USERNAME: ${{ github.actor }}
TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir reports
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
db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json"
jq -e '(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated |
((now - $updated) >= 0) and ((now - $updated) <= 86400)' \
"$db_metadata" >/dev/null
cp "$db_metadata" reports/trivy-db.json
jq -er '.subjects[] | [.image, .platform, .digest, .repository] | @tsv' release-evidence/manifest.json |
while IFS=$'\t' read -r image platform digest repository; do
suffix=${platform//\//-}
Expand Down
21 changes: 3 additions & 18 deletions .github/workflows/reusable-docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -511,24 +511,9 @@ jobs:
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
# A blocking gate inherits Trivy's database availability as a hard dependency, so the
# retries and the mirror fallback live in one action rather than in each caller.
- uses: ./.github/actions/download-trivy-db

- name: Enforce the release vulnerability policy
env:
Expand Down
2 changes: 2 additions & 0 deletions docs/contributor/ci-cd.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ why the label is the authority, and which alternatives were priced and declined,
| `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 |
| `rescan-main-images.yml` | Weekly, manual | Rescans main's published images against the release vulnerability policy and keeps one tracking issue in step with the findings |
| `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 |
| `verify-changesets.yml`| Called by cicd.yml | Tests changeset/version-sync policy and enforces release-note presence |
Expand All @@ -255,6 +256,7 @@ why the label is the authority, and which alternatives were priced and declined,
| `setup-caches` | Restores Maven and generated-client caches for one validated `cache-type` |
| `setup-browsers` | Restores the exact Playwright browser version and installs Chromium system dependencies |
| `setup-release-security-tools` | Installs Cosign, Trivy, and optionally Syft for release-evidence jobs |
| `download-trivy-db` | Fetches the vulnerability database with retries and a mirror fallback; optionally refuses one over `max-age-hours` old and records its metadata |
| `ghcr-login` | Authenticates Docker to GHCR |

Repository-local actions require checkout first. Jobs without checkout use the external SHA-pinned
Expand Down
14 changes: 14 additions & 0 deletions docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ The latest release is rescanned weekly. A policy violation or scan failure is re
[the vulnerability response issue](https://github.com/ls1intum/Hephaestus/issues/1369); scanner failure
is never treated as no findings.

`main`'s images are rescanned on the same weekly cadence, by `rescan-main-images.yml`. Container
vulnerabilities are time-dependent, not commit-dependent: the build gate can only catch a finding some
change introduced, and the finding that blocked v0.75.0 was published after the code had merged clean.
The scheduled run resolves each image's `:main` tag to its `linux/amd64` digest and evaluates the same
policy through the same script, so nothing about the verdict differs from a release.

That run is **not** blocking, and no scheduled run should be. Nobody triggered it, no commit caused the
finding and no revert removes it — the remedy is to rebuild `main` against a patched base image, which
the next merge does anyway. So `scripts/report-vulnerability-drift.ts` keeps a single tracking issue in
step with the findings instead: opened when they appear, edited in place and commented when the set
changes, silent when it has not, and closed when it clears. It recognises its own issue by an HTML
marker on the first line of the body, not by the title, so the issue can be retitled and triaged freely;
delete that line and the next run opens a new one.

## Browser source maps

Image builds upload hidden source maps through Sentry's debug-ID integration when the repository
Expand Down
87 changes: 84 additions & 3 deletions scripts/ci-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ void describe("CI contract", () => {
assert.doesNotMatch(scan, /linux\/arm64/);
// A gate that cannot say what it rejected is not finished.
assert.match(scan, /uses: actions\/upload-artifact@/);
assert.match(scan, /public\.ecr\.aws\/aquasecurity\/trivy-db/);
assert.match(scan, /uses: \.\/\.github\/actions\/download-trivy-db/);

let callSites = 0;
for (const [file, source] of await workflowSources()) {
Expand All @@ -283,11 +283,92 @@ void describe("CI contract", () => {
);
}
}
// The blocking build gate and the scheduled rescan; the release path goes through
// verify-release-evidence.ts, which imports evaluate() directly.
// The blocking build gate and the scheduled release rescan; the release path goes through
// verify-release-evidence.ts and the main rescan through scan-main-images.ts, both of which
// reach the same evaluator without a workflow-level call site.
assert.equal(callSites, 2);
});

void test("keeps one release vulnerability policy behind every scan", async () => {
// Every path that evaluates the policy — the build gate, the release, the release rescan and
// the main rescan — must name this one file. A second copy is the failure the whole effort
// removes, and it would be invisible: two policies both pass until they disagree.
const files = [
...(await Array.fromAsync(glob(".github/workflows/*.{yml,yaml}"))),
...(await Array.fromAsync(glob("scripts/*.ts"))),
];
for (const [file, source] of await readSources(files)) {
if (file.endsWith(".test.ts")) continue;
for (const reference of source.match(/[\w./-]*vulnerability-polic[\w-]*\.json/g) ?? [])
assert.ok(
// The evidence bundle carries a copy so a release can be re-audited against the
// policy it was cut under; release.yml is asserted below to copy, not author, it.
["security/vulnerability-policy.json", "evidence/vulnerability-policy.json"].includes(
reference,
) || reference === "vulnerability-policy.json",
`${file} must evaluate the one release vulnerability policy, not ${reference}`,
);
}
assert.match(
await readFile(".github/workflows/release.yml", "utf8"),
/cp security\/vulnerability-policy\.json evidence\/vulnerability-policy\.json/,
);
// One committed policy, so "the same policy" is a fact rather than a convention.
assert.deepEqual(await Array.fromAsync(glob("security/*vulnerability*.json")), [
"security/vulnerability-policy.json",
]);
});

void test("rescans main's images weekly and reports drift to an issue, not a status", async () => {
const source = await readFile(".github/workflows/rescan-main-images.yml", "utf8");
// Weekly, matching the supported-release rescan: the remedy for drift is a rebuild of main,
// which the next merge performs anyway, so a nightly run would report the same finding six
// more times before anything could have changed.
assert.match(source, /^ {4}- cron: "\d+ \d+ \* \* 1"$/m);
assert.match(source, /workflow_dispatch:/);
assert.match(source, /group: rescan-main-images/);
// Writing an issue is the whole point; nothing else here needs a write.
assert.match(source, /issues: write/);
assert.doesNotMatch(source, /contents: write|packages: write|id-token: write/);
// The scan and the reporting are tested TypeScript, not inline bash: the upsert in
// particular has to not open a duplicate every week, which no eyeball review establishes.
assert.match(source, /run: node scripts\/scan-main-images\.ts reports/);
assert.match(source, /run: node scripts\/report-vulnerability-drift\.ts reports/);
assert.match(source, /uses: \.\/\.github\/actions\/download-trivy-db/);
assert.match(source, /uses: actions\/upload-artifact@/);
// Nothing may turn a finding into a red status: no `continue-on-error` fig leaf, and no
// second evaluator inline.
assert.doesNotMatch(source, /continue-on-error/);
assert.doesNotMatch(source, /trivy image/);
});

void test("fetches the Trivy database through one action", async () => {
const action = await readFile(".github/actions/download-trivy-db/action.yml", "utf8");
// GHCR answers TOOMANYREQUESTS often enough that a gate needs the mirror; stating it once
// is why this action exists at all.
assert.match(action, /public\.ecr\.aws\/aquasecurity\/trivy-db/);
assert.match(action, /--download-db-only/);
for (const [file, source] of await workflowSources())
assert.doesNotMatch(
source,
/--download-db-only/,
`${file} must download the Trivy database through .github/actions/download-trivy-db`,
);
// Everything that signs or publishes a scan result asserts the database is not stale.
for (const file of [
".github/workflows/release.yml",
".github/workflows/rescan-release-images.yml",
".github/workflows/rescan-main-images.yml",
]) {
const source = await readFile(file, "utf8");
assert.match(
source,
/uses: \.\/\.github\/actions\/download-trivy-db\n\s+with:\n\s+max-age-hours: "24"/,
`${file} must refuse a stale Trivy database`,
);
}
});

void test("pins every external action to a full commit SHA with a version comment", async () => {
const invalid: string[] = [];
const files = await Array.fromAsync(glob(".github/{actions,workflows}/**/*.{yml,yaml}"));
Expand Down
Loading
Loading