Skip to content
Closed
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
27 changes: 27 additions & 0 deletions .github/workflows/lint-and-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,6 +247,7 @@ jobs:
- unbranded-unit-tests
- branded-unit-tests
- workflow-security-lint
- cve-exception-expiry
if: >-
always()
&& github.event_name == 'push'
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}"
31 changes: 31 additions & 0 deletions exceptions/README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions exceptions/stackstate-k8s-agent/CVE-2025-15367.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions exceptions/stackstate-k8s-agent/CVE-2026-4360.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
122 changes: 122 additions & 0 deletions scripts/check_cve_exception_expiry.py
Original file line number Diff line number Diff line change
@@ -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 "<no vulnerability.id>"
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())
70 changes: 70 additions & 0 deletions scripts/check_cve_exception_expiry_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading