From 640f8ddc4ae4f24c54aca92f314eced2a35a3f13 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 28 Aug 2026 20:32:24 +0000 Subject: [PATCH 1/4] Fail CI when a CVE exception has passed its expires date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exceptions/ tree carries `expires` dates that nothing enforced. The scan-image evaluator does fail closed on expiry — a missing, unparseable or past date turns the exception back into a live finding — but its exit code is 0 unconditionally in `inform` mode, and that is the mode this repo's only consumer of the tree runs in (the report-only scan in build-deb.yml, since #487). The gating chart scan in StackVista/cve-reporter does not read the tree at all, so the dates were advisory. All four current entries expire 2026-09-04, two of them standing deferrals for Python advisories on the embedded 3.13.15 interpreter where no released fix exists. With this wired into the required roll-up the check goes red the day after an entry lapses, so renewing one means re-verifying upstream rather than letting a deferral drift into a silent permanent acceptance. It warns for two weeks beforehand so the red is never a surprise. The job needs no secrets and no self-hosted runner, so unlike every other job in this workflow it also covers fork pull requests. Co-authored-by: Cve Ticket Reconciler --- .github/workflows/lint-and-unit-tests.yml | 26 +++++ scripts/check_cve_exception_expiry.py | 110 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100755 scripts/check_cve_exception_expiry.py diff --git a/.github/workflows/lint-and-unit-tests.yml b/.github/workflows/lint-and-unit-tests.yml index 52d7a41b0fc2..176341d06a65 100644 --- a/.github/workflows/lint-and-unit-tests.yml +++ b/.github/workflows/lint-and-unit-tests.yml @@ -83,6 +83,28 @@ jobs: set -euo pipefail pipx run --spec "zizmor==${ZIZMOR_VERSION}" zizmor --collect=workflows,actions . + cve-exception-expiry: + name: CVE exception expiry + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Check exceptions/ for lapsed CVE deferrals + env: + # renovate: datasource=pypi depName=PyYAML + PYYAML_VERSION: 6.0.3 + run: | + set -euo pipefail + python3 -m venv /tmp/exception-lint + /tmp/exception-lint/bin/pip install --quiet --disable-pip-version-check "PyYAML==${PYYAML_VERSION}" + /tmp/exception-lint/bin/python scripts/check_cve_exception_expiry.py + mod-tidy: name: Go module tidiness (go.mod / go.sum) if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -224,6 +246,7 @@ jobs: - unbranded-unit-tests - branded-unit-tests - workflow-security-lint + - cve-exception-expiry if: >- always() && github.event_name == 'push' @@ -244,6 +267,7 @@ jobs: - unbranded-unit-tests - branded-unit-tests - workflow-security-lint + - cve-exception-expiry if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -256,6 +280,7 @@ jobs: UNBRANDED_UNIT_TESTS: ${{ needs.unbranded-unit-tests.result }} BRANDED_UNIT_TESTS: ${{ needs.branded-unit-tests.result }} WORKFLOW_SECURITY_LINT: ${{ needs.workflow-security-lint.result }} + CVE_EXCEPTION_EXPIRY: ${{ needs.cve-exception-expiry.result }} run: | set -euo pipefail status=0 @@ -273,5 +298,6 @@ jobs: require_success "Unit tests (branded)" "${BRANDED_UNIT_TESTS}" require_success "Go module tidiness" "${MOD_TIDY}" require_success "Workflow security lint" "${WORKFLOW_SECURITY_LINT}" + require_success "CVE exception expiry" "${CVE_EXCEPTION_EXPIRY}" exit "${status}" diff --git a/scripts/check_cve_exception_expiry.py b/scripts/check_cve_exception_expiry.py new file mode 100755 index 000000000000..885f5a5a219b --- /dev/null +++ b/scripts/check_cve_exception_expiry.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Fail when a CVE exception under exceptions/ has passed its expires date. + +The image scan already treats an expired exception as a live finding, but it runs +in `inform` mode here, so its exit code is always 0 and the dates never block +anything. The gating chart scan in StackVista/cve-reporter does not read this +tree at all. This check is what makes `expires` a real deadline. +""" + +import argparse +import datetime +import pathlib +import sys + +import yaml + +DEFAULT_WARN_DAYS = 14 + + +def load(path): + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def check(path, today, warn_days): + """Return (errors, warnings) for one exception file.""" + try: + doc = load(path) + except (yaml.YAMLError, OSError) as exc: + return [f"{path}: cannot be parsed: {exc}"], [] + + cve = (doc.get("vulnerability") or {}).get("id") or "" + raw = doc.get("expires") + + if raw is None or str(raw).strip() == "": + return [f"{path}: {cve} has no expires date"], [] + + # The scan treats an unparseable date as already expired, so an exception + # that looks valid but is not parseable must fail here too rather than + # sitting in the tree looking effective. + if isinstance(raw, datetime.date): + expires = raw + else: + try: + expires = datetime.date.fromisoformat(str(raw).strip()) + except ValueError: + return [f"{path}: {cve} has an unparseable expires date {raw!r} (want YYYY-MM-DD)"], [] + + # Same boundary as the scan evaluator: valid through the expires date, dead + # the day after. + if today > expires: + days = (today - expires).days + return [ + f"{path}: {cve} expired {days} day(s) ago on {expires.isoformat()} — " + "re-verify upstream and either renew with a new date or drop the exception" + ], [] + + remaining = (expires - today).days + if remaining <= warn_days: + return [], [f"{path}: {cve} expires in {remaining} day(s) on {expires.isoformat()}"] + + return [], [] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--exceptions-dir", + default=str(pathlib.Path(__file__).resolve().parent.parent / "exceptions"), + help="Directory tree of exception YAML files.", + ) + parser.add_argument( + "--warn-days", + type=int, + default=DEFAULT_WARN_DAYS, + help=f"Warn when an exception expires within this many days (default {DEFAULT_WARN_DAYS}).", + ) + args = parser.parse_args() + + root = pathlib.Path(args.exceptions_dir) + if not root.is_dir(): + print(f"ERROR: exceptions directory {root} does not exist") + return 1 + + # The scan evaluator resolves expiry against UTC; matching it keeps the two + # from disagreeing for a few hours a day. + today = datetime.datetime.now(datetime.timezone.utc).date() + + errors = [] + warnings = [] + paths = sorted(root.rglob("*.yaml")) + for path in paths: + file_errors, file_warnings = check(path, today, args.warn_days) + errors.extend(file_errors) + warnings.extend(file_warnings) + + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}") + + print(f"Checked {len(paths)} exception file(s) against {today.isoformat()}.") + if errors: + print(f"{len(errors)} exception(s) are expired or malformed.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 020b93c163bb9aecf9a0ecc40183a6c3eddb6cbd Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 28 Aug 2026 20:32:34 +0000 Subject: [PATCH 2/4] Document what the exceptions tree does and does not enforce Nothing explained the tree, so the split between the report-only scan that consumes it and the gating chart scan that ignores it was only discoverable by reading both pipelines. Someone hitting the new expiry check needs to know that the fix is to re-verify upstream, not to push the date out. Co-authored-by: Cve Ticket Reconciler --- exceptions/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 exceptions/README.md diff --git a/exceptions/README.md b/exceptions/README.md new file mode 100644 index 000000000000..aaff31701be3 --- /dev/null +++ b/exceptions/README.md @@ -0,0 +1,31 @@ +# CVE exceptions + +One YAML file per `(image, CVE)`, under a directory named for the consuming image. +The schema is owned by [image-pipeline](https://github.com/StackVista/image-pipeline) +(`schemas/exception.schema.json`); `schema_version: '1'` is the only version the +evaluator accepts, and it rejects duplicate `(image, CVE)` pairs. + +An entry records a *deferral with a deadline*, not an acceptance. `expires` is a +short review date by which someone re-verifies upstream and either renews it with a +fresh date or deletes the file because a fix shipped. + +## What enforces what + +`build-deb.yml` passes this tree to the `scan-image` action, which suppresses a +matching finding while the exception is current and turns it back into a live +finding once `expires` has passed or cannot be parsed. That scan runs in +`mode: inform`, so it reports but never fails, and the gating chart scan in +[cve-reporter](https://github.com/StackVista/cve-reporter) does not read this tree +at all. + +`scripts/check_cve_exception_expiry.py` is therefore what makes the dates real: it +fails the `CI success (lint and unit tests)` check when an entry has expired or +carries an unparseable date, and warns for two weeks beforehand. Run it locally +with `python3 scripts/check_cve_exception_expiry.py`. + +## When the check goes red + +Re-verify the advisory upstream first — that is the whole point of the date. Then +either drop the file if a compatible fix now exists, or renew `expires` and say in +`statement` what you checked and when. Do not extend a date without re-checking, +and do not silence a finding here that a version bump could fix instead. From dd33308779367b0f70b04f648bf3ac1a0b0eb7d0 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 28 Aug 2026 20:32:35 +0000 Subject: [PATCH 3/4] Refresh the Python exception statements against today's upstream check Both recorded a 2026-08-21 re-check and pointed at #489, which was opened for the OpenSSL exception and is superseded now that 3.5.8 is pinned. Re-verified against upstream CPython tags: 3.13.15 is still the newest 3.13 and 3.15 has only reached 3.15.0rc1, so the 3.15.0a6 fix CVE-2025-15367 advertises exists in no released version. Point them at the coordination ticket that actually tracks the batch. Co-authored-by: Cve Ticket Reconciler --- exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml | 7 ++++--- exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml b/exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml index 73f47fa6f9d5..8aad90d39181 100644 --- a/exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml +++ b/exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml @@ -23,6 +23,7 @@ statement: | outside the patch commitment, so the date above is a short review deadline rather than an acceptance. - Re-checked on 2026-08-21 against the embedded 3.13.15 build: still no 3.13 - backport. Re-check again before renewing. Tracked in - StackVista/stackstate-agent#489. + Re-checked on 2026-08-28 against upstream CPython tags: 3.13.15 is still the + newest 3.13, and 3.15 has only reached 3.15.0rc1, so the advertised 3.15.0a6 + fix is not available in any released version. Re-check again before renewing. + Tracked in StackVista/cve-reporter#29. diff --git a/exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml b/exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml index fcd6e56eefb3..8de3bf4a2791 100644 --- a/exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml +++ b/exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml @@ -20,6 +20,6 @@ statement: | line, so no patch exists to apply. The date above is a short review deadline, not an acceptance. - Re-checked on 2026-08-21 against the embedded 3.13.15 build: scanners still - report no fixed version. Re-check again before renewing. Tracked in - StackVista/stackstate-agent#489. + Re-checked on 2026-08-28 against the embedded 3.13.15 build: scanners still + report no fixed version, and 3.13.15 remains the newest upstream 3.13. Re-check + again before renewing. Tracked in StackVista/cve-reporter#29. From c9414b9659ce95652d6c3d9c3bd28656622d47ad Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Sun, 30 Aug 2026 08:18:43 +0200 Subject: [PATCH 4/4] Make CVE exception date parsing match the evaluator Use the evaluator's exact YYYY-MM-DD grammar and cover divergent ISO forms with checked tests so the CI guard cannot accept an exception the scan rejects. Co-authored-by: Louis Lotter --- .github/workflows/lint-and-unit-tests.yml | 1 + scripts/check_cve_exception_expiry.py | 20 +++++-- scripts/check_cve_exception_expiry_test.py | 70 ++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 scripts/check_cve_exception_expiry_test.py diff --git a/.github/workflows/lint-and-unit-tests.yml b/.github/workflows/lint-and-unit-tests.yml index 176341d06a65..54bf7c8c128c 100644 --- a/.github/workflows/lint-and-unit-tests.yml +++ b/.github/workflows/lint-and-unit-tests.yml @@ -103,6 +103,7 @@ jobs: set -euo pipefail python3 -m venv /tmp/exception-lint /tmp/exception-lint/bin/pip install --quiet --disable-pip-version-check "PyYAML==${PYYAML_VERSION}" + /tmp/exception-lint/bin/python scripts/check_cve_exception_expiry_test.py /tmp/exception-lint/bin/python scripts/check_cve_exception_expiry.py mod-tidy: diff --git a/scripts/check_cve_exception_expiry.py b/scripts/check_cve_exception_expiry.py index 885f5a5a219b..b030a9d42567 100755 --- a/scripts/check_cve_exception_expiry.py +++ b/scripts/check_cve_exception_expiry.py @@ -8,13 +8,16 @@ """ import argparse +import collections.abc import datetime import pathlib +import re import sys import yaml DEFAULT_WARN_DAYS = 14 +EXPIRES_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}\Z") def load(path): @@ -29,7 +32,14 @@ def check(path, today, warn_days): except (yaml.YAMLError, OSError) as exc: return [f"{path}: cannot be parsed: {exc}"], [] - cve = (doc.get("vulnerability") or {}).get("id") or "" + if not isinstance(doc, collections.abc.Mapping): + return [f"{path}: document must be a YAML mapping"], [] + + vulnerability = doc.get("vulnerability") or {} + if not isinstance(vulnerability, collections.abc.Mapping): + return [f"{path}: vulnerability must be a YAML mapping"], [] + + cve = vulnerability.get("id") or "" raw = doc.get("expires") if raw is None or str(raw).strip() == "": @@ -38,13 +48,15 @@ def check(path, today, warn_days): # The scan treats an unparseable date as already expired, so an exception # that looks valid but is not parseable must fail here too rather than # sitting in the tree looking effective. - if isinstance(raw, datetime.date): + if type(raw) is datetime.date: expires = raw - else: + elif isinstance(raw, str) and EXPIRES_PATTERN.fullmatch(raw): try: - expires = datetime.date.fromisoformat(str(raw).strip()) + expires = datetime.date.fromisoformat(raw) except ValueError: return [f"{path}: {cve} has an unparseable expires date {raw!r} (want YYYY-MM-DD)"], [] + else: + return [f"{path}: {cve} has an unparseable expires date {raw!r} (want YYYY-MM-DD)"], [] # Same boundary as the scan evaluator: valid through the expires date, dead # the day after. diff --git a/scripts/check_cve_exception_expiry_test.py b/scripts/check_cve_exception_expiry_test.py new file mode 100644 index 000000000000..7a52f3e400c0 --- /dev/null +++ b/scripts/check_cve_exception_expiry_test.py @@ -0,0 +1,70 @@ +import datetime +import pathlib +import tempfile +import unittest + +import check_cve_exception_expiry + + +class CheckCveExceptionExpiryTest(unittest.TestCase): + today = datetime.date(2026, 8, 30) + + def check_document(self, document, warn_days=14): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "exception.yaml" + path.write_text(document, encoding="utf-8") + return check_cve_exception_expiry.check(path, self.today, warn_days) + + def test_accepts_quoted_canonical_date(self): + errors, warnings = self.check_document('expires: "2026-09-20"\n') + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_accepts_unquoted_canonical_date(self): + errors, warnings = self.check_document("expires: 2026-09-20\n") + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_accepts_expiry_today(self): + errors, warnings = self.check_document("expires: 2026-08-30\n") + self.assertEqual(errors, []) + self.assertEqual(len(warnings), 1) + + def test_rejects_expired_date(self): + errors, warnings = self.check_document("expires: 2026-08-29\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_compact_date(self): + errors, warnings = self.check_document("expires: 20260920\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_iso_week_date(self): + errors, warnings = self.check_document("expires: 2026-W38-7\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_invalid_calendar_date(self): + errors, warnings = self.check_document('expires: "2026-02-30"\n') + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_missing_date(self): + errors, warnings = self.check_document("vulnerability:\n id: CVE-2026-0001\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_malformed_yaml(self): + errors, warnings = self.check_document("expires: [\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + def test_rejects_non_mapping_document(self): + errors, warnings = self.check_document("- expires: 2026-09-20\n") + self.assertEqual(len(errors), 1) + self.assertEqual(warnings, []) + + +if __name__ == "__main__": + unittest.main()