Files
computer-playbook/tests/unit/python/utils/security/test_suppressions.py
T
kevinveenbirkenbachandClaude Opus 5.5 6b36c8579a fix(sys-ctl-hlth-runtime): scan with Trivy, Grype and OSV instead of PHP floors
The runtime check judged PHP by its upstream version and failed every
container on an end-of-life line. Distribution builds backport fixes without
raising that number, so main run 41154895895 failed Checkmk#0 on
`PHP 8.1.2-1ubuntu2.28 runs the end-of-life line 8.1`, a build Ubuntu lists
as fixed for CVE-2026-109618 and CVE-2026-109619.

- The role now runs Trivy, Grype and OSV-Scanner, with a Syft SBOM per
  target, against the host and the images of the running containers, in
  bounded parallel lanes. Targets, engines and the failing severities are
  lists in meta/services.yml; the default is every target and engine at
  CRITICAL and HIGH with a fix available.
- Grype always scans the binary-classified packages of the SBOM, so a PHP
  built from source in an official php image (8.4.26) still fails on the
  CVEs fixed in 8.4.27, which Trivy and OSV do not see.
- OSV reads an SBOM copy whose distro purls carry the source package name.
- A CVE is suppressed only in the role owning the image, under
  services.<service>.security.suppressions with id, until and a
  `# nocheck: cve <reason>` marker; a lint rejects bare, malformed and
  expired entries.
- The deploy-time run fails on its first finding: sys-service gains
  system_service_run_once and sys-service-terminator skips the retries
  only for those units. The timer runs daily at 00:00 UTC.
- The PHP version floors are removed; the php_hardening check stays.
- make test-security replaces make security-runtimes: Syft builds an SBOM
  of every pinned image straight from the GHCR mirror, cached by digest,
  and the three engines scan it with the role's threshold and suppressions.

Part of #1324

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:44:58 +01:00

85 lines
2.9 KiB
Python

from __future__ import annotations
import datetime as dt
import tempfile
from pathlib import Path
from unittest import TestCase, main
from utils.security.suppressions import (
Suppression,
SuppressionError,
parse_entries,
role_suppressions,
service_suppressions,
)
class TestParseEntries(TestCase):
def test_a_date_until_is_kept(self):
parsed = parse_entries(
[{"id": "CVE-2026-1234", "until": dt.date(2026, 12, 31)}], "r:s"
)
self.assertEqual(parsed, [Suppression("CVE-2026-1234", dt.date(2026, 12, 31))])
def test_a_quoted_until_is_parsed(self):
parsed = parse_entries([{"id": "CVE-2026-1234", "until": "2026-12-31"}], "r:s")
self.assertEqual(parsed[0].until, dt.date(2026, 12, 31))
def test_a_ghsa_id_is_rejected(self):
with self.assertRaisesRegex(SuppressionError, "no CVE-YYYY-NNNN id"):
parse_entries([{"id": "GHSA-aaaa-bbbb-cccc", "until": "2026-12-31"}], "r:s")
def test_a_missing_until_is_rejected(self):
with self.assertRaisesRegex(SuppressionError, "until is required"):
parse_entries([{"id": "CVE-2026-1234"}], "r:s")
def test_a_timestamp_until_is_rejected(self):
with self.assertRaisesRegex(SuppressionError, "not a timestamp"):
parse_entries(
[
{
"id": "CVE-2026-1234",
"until": dt.datetime(2026, 12, 31, tzinfo=dt.UTC),
}
],
"r:s",
)
def test_an_unknown_key_is_rejected(self):
with self.assertRaisesRegex(SuppressionError, "unknown key"):
parse_entries(
[{"id": "CVE-2026-1234", "until": "2026-12-31", "reason": "x"}], "r:s"
)
def test_a_mapping_instead_of_a_list_is_rejected(self):
with self.assertRaisesRegex(SuppressionError, "must be a list"):
parse_entries({"id": "CVE-2026-1234"}, "r:s")
class TestServiceSuppressions(TestCase):
def test_services_without_security_are_left_out(self):
services = {
"app": {
"image": "app",
"security": {
"suppressions": [{"id": "CVE-2026-1001", "until": "2026-12-31"}]
},
},
"db": {"image": "postgres"},
}
self.assertEqual(list(service_suppressions(services, "role")), ["app"])
def test_security_without_suppressions_is_rejected(self):
with self.assertRaisesRegex(
SuppressionError, "security must hold suppressions"
):
service_suppressions({"app": {"security": {}}}, "role")
def test_a_role_without_services_file_has_none(self):
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(role_suppressions(Path(tmp), "web-app-none"), {})
if __name__ == "__main__":
main()