Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions docs/contributor/ci-cd.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ source validation.
- **Renovate** – Monitors dependencies for vulnerabilities
- **Environment protection** – Production requires approval

What a Trivy image finding has to be before it blocks, and what an exception has to contain, is in
[Vulnerability remediation](./vulnerability-remediation.mdx).

### CodeQL Default Setup

CodeQL runs automatically via GitHub's Default Setup (enabled in repository settings), providing:
Expand Down
3 changes: 3 additions & 0 deletions docs/contributor/release-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ changes, silent when it has not, and closed when it clears. It recognises its ow
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.

[Vulnerability remediation](./vulnerability-remediation.mdx) states the policy in full — what an
exception must contain, and why VEX is not adopted.

## Browser source maps

Image builds upload hidden source maps through Sentry's debug-ID integration when the repository
Expand Down
99 changes: 99 additions & 0 deletions docs/contributor/vulnerability-remediation.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
id: vulnerability-remediation
sidebar_position: 5
title: Vulnerability remediation
description: What blocks a build or a release, why unfixable findings do not, and how to write an exception.
---

# Vulnerability remediation

The policy is executable: `scripts/check-release-vulnerabilities.ts` evaluates a Trivy report against
`security/vulnerability-policy.json`. This page states it in prose, which is what
[NIST SSDF](https://csrc.nist.gov/projects/ssdf) RV.1.3 asks for. RV.2.1 accepts "remediation **or
other risk response**" and prescribes no thresholds — only that ours are written down.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## What blocks

A finding blocks when it is **HIGH or CRITICAL and upstream has published a fix** — Trivy reports a
non-empty `FixedVersion`. MEDIUM and below never block, at any scan.

The fixable/unfixable split is the part most easily read wrong, so plainly: **an unfixable HIGH is not
an exception and does not need one.** Four of the images in `security/release-images.json` are upstream
— alpine, nats, nginx, and traefik — and cannot be patched here at all, so failing on a finding that
has no fix removes no risk and leaves nobody an action. The share is not marginal: when the gate was
first measured against real images, `agent-pi` reported 59 HIGH/CRITICAL findings of which 1 was
fixable, and `postgres` 99 of which 23 were.

Gating on fix availability is the ordinary shape of this control. Google Binary Authorization exposes
`maximumFixableSeverity` and `maximumUnfixableSeverity` as separate settings; Docker Scout's default
policy is "no **fixable** critical or high vulnerabilities"; Snyk separates `--fail-on=upgradable` from
`--severity-threshold`; Chainguard's remediation SLA starts when a patch exists rather than when the
CVE is published.

Unfixable findings are still counted. The filter lives in the evaluator, not in a `--ignore-unfixed`
flag on Trivy, so every HIGH and CRITICAL stays in the scan report and in the `highCritical` list of
the `.policy.json` attached to the release. Suppressing the *failure* is the intent; suppressing the
*record* is not, and the evidence bundle is signed on the assumption that it is complete.

## Where the gate runs

| When | Subject | Effect |
| --- | --- | --- |
| Every image build (`reusable-docker-build.yml`) | the `linux/amd64` image the run just pushed | fails the pull request |
| Release evidence (`release.yml`) | both platforms of every first-party and upstream image | no release is created |
| Weekly (`rescan-main-images.yml`) | the `linux/amd64` image behind each `:main` tag | opens or updates a tracking issue |
| Weekly (`rescan-release-images.yml`) | every subject in the latest published release | reports on [the vulnerability response issue](https://github.com/ls1intum/Hephaestus/issues/1369) |

All four call the same script with the same policy file; `scripts/ci-contract.test.ts` asserts there is
no second copy of either. The two scheduled rescans deliberately do not block: a finding published
after a clean merge is nobody's commit and no revert removes it, so a red status would name no
culprit and demand no action — [Release management](./release-management.mdx#supply-chain-evidence)
has how the drift is routed instead. A scanner failure is never treated as no findings.

## Exceptions

An exception lets one fixable finding through for a bounded time. It is an entry in
`security/vulnerability-policy.json`, and `security/` has no CODEOWNERS rule of its own, so the default
one applies: a member of the @ls1intum/hephaestus-maintainers team has to approve it.

- `image`, `platform`, `vulnerability`, `package`, `installedVersion` — the match key. All five bind
exactly, and `installedVersion` is what makes an exception die when the package moves.
- `digest` — recorded so the exception names the artefact it was reviewed against, but deliberately
**not** matched on. A 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.
- `owner` — who is accountable for retiring it.
- `status` — `affected` or `not_affected`, plus a `justification` in prose.
- `evidence` — an HTTPS URL: the upstream advisory, the tracking issue, or the analysis the claim rests
on. A link a reviewer can follow, not a sentence.
- `expires` — RFC 3339 UTC, in the future, and **at most 90 days out**. The cap is not a renewal-free
window; at 90 days the gate fails again and the finding is argued again with whatever upstream has
published since.

### `not_affected` justifications

A `not_affected` exception also carries a `justificationCategory`, one of the five in CISA's *Minimum
Requirements for Vulnerability Exploitability eXchange (VEX)*. Naming one forces the claim to say which
way the finding does not apply, so prose like "not reachable" cannot stand in for an analysis nobody
did.

| Value | Means |
| --- | --- |
| `component_not_present` | the vulnerable component is not in the image |
| `vulnerable_code_not_present` | the component ships, the affected code does not |
| `vulnerable_code_not_in_execute_path` | the affected code ships but is never called |
| `vulnerable_code_cannot_be_controlled_by_adversary` | it is called, but never with attacker-controlled input |
| `inline_mitigations_already_exist` | it is reachable and controllable, but a compensating control blocks exploitation |

A value outside those five is a malformed policy and throws. An `affected` exception carries no
category — it defers a risk we concede applies, and claiming non-exploitability at the same time is a
contradiction.

## VEX is not adopted

The vocabulary above is all we take. [OpenVEX](https://github.com/openvex/spec) has been frozen at
v0.2.0 since 2023-08-22, and Trivy's `--vex` still carries an EXPERIMENTAL banner and matches on
digest-based PURLs that a locally built image does not have. `aquasecurity/vexhub`, Trivy's default VEX
repository, stopped updating on 2026-03-23 and has no OS-distro coverage at all — no Alpine, no Debian,
no nginx — and Alpine publishes no VEX itself, which is where most findings here originate. Adopting it
would mean hand-authoring documents that duplicate `security/vulnerability-policy.json`, minus the
expiry field OpenVEX does not define. Revisit if the distros start publishing.
2 changes: 1 addition & 1 deletion docs/sidebars.contributor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const sidebars: SidebarsConfig = {
{
type: "category",
label: "Operations",
items: ["release-management", "ci-cd"],
items: ["release-management", "ci-cd", "vulnerability-remediation"],
},
{
type: "category",
Expand Down
61 changes: 61 additions & 0 deletions scripts/check-release-vulnerabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ await test("accepts an owned, justified, unexpired exception", () => {
image: "server",
installedVersion: "1",
justification: "not reachable in the deployed configuration",
justificationCategory: "vulnerable_code_not_in_execute_path",
owner: "@security",
package: "lib",
platform: "linux/amd64",
Expand Down Expand Up @@ -115,6 +116,7 @@ await test("matches an exception without its digest, but still on every other fi
image: "server",
installedVersion: "1",
justification: "not reachable in the deployed configuration",
justificationCategory: "vulnerable_code_not_in_execute_path",
owner: "@security",
package: "lib",
platform: "linux/amd64",
Expand Down Expand Up @@ -214,6 +216,65 @@ await test("rejects expired and malformed exceptions", () => {
);
});

await test("holds a not_affected exception to one of CISA's five justifications", () => {
const exception = {
digest,
evidence: "https://github.com/example/project/issues/1",
expires: "2026-10-01T00:00:00Z",
image: "server",
installedVersion: "1",
justification: "the vulnerable parser is never handed untrusted input",
justificationCategory: "vulnerable_code_cannot_be_controlled_by_adversary",
owner: "@security",
package: "lib",
platform: "linux/amd64",
status: "not_affected",
vulnerability: "CVE-1",
};
const errorsWith = (override: Partial<typeof exception>): string =>
evaluate(
"server",
report,
{ ...policy, exceptions: [{ ...exception, ...override }] },
now,
).errors.join("\n");

for (const category of [
"component_not_present",
"vulnerable_code_not_present",
"vulnerable_code_not_in_execute_path",
"vulnerable_code_cannot_be_controlled_by_adversary",
"inline_mitigations_already_exist",
])
assert.equal(errorsWith({ justificationCategory: category }), "", category);

// A value outside the vocabulary is a malformed policy rather than a soft error: nothing
// downstream can interpret it, so there is no partial result worth reporting.
assert.throws(
() => errorsWith({ justificationCategory: "not_reachable" }),
/malformed vulnerability policy/,
);
const { justificationCategory: _omitted, ...uncategorised } = exception;
assert.match(
evaluate("server", report, { ...policy, exceptions: [uncategorised] }, now).errors.join("\n"),
/not_affected exception must name a justification category: CVE-1/,
);
// `affected` concedes the finding applies, so a not_affected justification contradicts it.
assert.match(
errorsWith({ status: "affected" }),
/affected exception must not name a justification category: CVE-1/,
);
assert.equal(
evaluate(
"server",
report,
{ ...policy, exceptions: [{ ...uncategorised, status: "affected" }] },
now,
).errors.join("\n"),
"",
);
});

await test("names the rejected findings in the run summary", () => {
const rendered = renderSummary(
{ digest, image: "server", platform: "linux/amd64" },
Expand Down
37 changes: 36 additions & 1 deletion scripts/check-release-vulnerabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ type Finding = {
VulnerabilityID?: string;
};

/**
* The five `not_affected` justifications from CISA's *Minimum Requirements for Vulnerability
* Exploitability eXchange (VEX)*. They are taken as a vocabulary only: a claim that a finding does
* not apply has to name which of the five ways it does not apply, so prose like "not reachable"
* cannot stand in for an analysis nobody did. VEX itself is not adopted — see
* `docs/contributor/vulnerability-remediation.mdx`.
*/
const JUSTIFICATION_CATEGORIES = [
"component_not_present",
"vulnerable_code_not_present",
"vulnerable_code_not_in_execute_path",
"vulnerable_code_cannot_be_controlled_by_adversary",
"inline_mitigations_already_exist",
] as const;

export type JustificationCategory = (typeof JUSTIFICATION_CATEGORIES)[number];

const isJustificationCategory = (value: unknown): value is JustificationCategory =>
JUSTIFICATION_CATEGORIES.some((category) => category === value);

// `digest` is recorded so an exception names the artefact it was reviewed against, but it is
// deliberately not part of the match key: the release digest does not exist until `tag-images`
// runs, so a digest-keyed exception could only ever be authored after the gate had already
Expand All @@ -20,6 +40,8 @@ type Exception = {
image: string;
installedVersion: string;
justification: string;
/** Required when `status` is `not_affected`, and forbidden otherwise. */
justificationCategory?: JustificationCategory;
owner: string;
package: string;
platform: string;
Expand All @@ -46,7 +68,9 @@ const isException = (value: unknown): value is Exception =>
"status",
"vulnerability",
].every((field) => typeof value[field] === "string") &&
(value.status === "affected" || value.status === "not_affected");
(value.status === "affected" || value.status === "not_affected") &&
(value.justificationCategory === undefined ||
isJustificationCategory(value.justificationCategory));

const isPolicy = (value: unknown): value is Policy =>
record(value) &&
Expand Down Expand Up @@ -106,6 +130,17 @@ export function evaluate(
errors.push(`malformed exception digest: ${exception.digest}`);
if (!/^linux\/(?:amd64|arm64)$/.test(exception.platform))
errors.push(`unsupported exception platform: ${exception.platform}`);
// An unrecognised category is already a malformed policy; what is left to check is that the
// category and the status agree. `affected` defers a risk we accept, and saying why the
// finding does not apply while conceding that it does is a contradiction, not a detail.
if (exception.status === "not_affected" && exception.justificationCategory === undefined)
errors.push(
`not_affected exception must name a justification category: ${exception.vulnerability}`,
);
if (exception.status === "affected" && exception.justificationCategory !== undefined)
errors.push(
`affected exception must not name a justification category: ${exception.vulnerability}`,
);
try {
if (new URL(exception.evidence).protocol !== "https:")
errors.push(`exception evidence must be an HTTPS URL: ${exception.evidence}`);
Expand Down
Loading