diff --git a/.github/workflows/lint-and-unit-tests.yml b/.github/workflows/lint-and-unit-tests.yml index 52d7a41b0fc2..54bf7c8c128c 100644 --- a/.github/workflows/lint-and-unit-tests.yml +++ b/.github/workflows/lint-and-unit-tests.yml @@ -83,6 +83,29 @@ 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_test.py + /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 +247,7 @@ jobs: - unbranded-unit-tests - branded-unit-tests - workflow-security-lint + - cve-exception-expiry if: >- always() && github.event_name == 'push' @@ -244,6 +268,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 +281,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 +299,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/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. 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. diff --git a/scripts/check_cve_exception_expiry.py b/scripts/check_cve_exception_expiry.py new file mode 100755 index 000000000000..b030a9d42567 --- /dev/null +++ b/scripts/check_cve_exception_expiry.py @@ -0,0 +1,122 @@ +#!/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 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): + 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}"], [] + + 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() == "": + 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 type(raw) is datetime.date: + expires = raw + elif isinstance(raw, str) and EXPIRES_PATTERN.fullmatch(raw): + try: + 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. + 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()) 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()