mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-15 06:58:53 +00:00
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>
85 lines
2.9 KiB
Python
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()
|