diff --git a/README.md b/README.md index cab2e0f..c5db53d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,15 @@ against the source-package minimum. A newer downloaded table cannot silently rem the kernel mappings shipped with this client. If it lacks those mappings, the client reports the mismatch and retains its installed table. +On Ubuntu 22.04 and 24.04, the `generic` and `generic-64k` kernels built from +the GA `linux` source package (5.15 and 6.8) are also graded per CVE against +the Ubuntu tracker for +[CVE-2026-64531](https://ubuntu.com/security/CVE-2026-64531) and +[CVE-2026-64378](https://ubuntu.com/security/CVE-2026-64378). A `needed` or +`pending` tracker state fails because Ubuntu has not released a fix. A +`released` state compares the running ABI with the published fixed ABI after +the standard bulletin grace period. Other kernel trains are not applicable. + An absent host Docker installation or an absent CUDA Toolkit in the inspected scope remains not applicable. This differs from an unreadable tool or failed probe, which remains unknown. A complete bare-metal PCI scan with no VFIO diff --git a/cmax/audit_report.py b/cmax/audit_report.py index 8375060..26d8569 100644 --- a/cmax/audit_report.py +++ b/cmax/audit_report.py @@ -112,6 +112,10 @@ def _harness_not_applicable(harness: str) -> str: "security.guestKernel.newerInstalled": ( "The audit did not find a newer installed kernel that is waiting for a reboot." ), + "security.guestKernel.ubuntuCveStatus": ( + "Ubuntu tracks a released fix for each graded kernel CVE and the running " + "kernel meets it." + ), "security.ubuntuNobleKernelMinimum.status": ( "The running Ubuntu Noble kernel meets the aggregate published security minimum." ), diff --git a/cmax/criteria_links.py b/cmax/criteria_links.py index 1fed6fe..3a9dd1f 100644 --- a/cmax/criteria_links.py +++ b/cmax/criteria_links.py @@ -97,6 +97,7 @@ "securityVersions.dpuHostIsolation.status": _SECURITY_ISOLATION, "security.januscape.status": _SECURITY_ESCALATION, "security.guestKernel.newerInstalled": _SECURITY_PATCHING, + "security.guestKernel.ubuntuCveStatus": _SECURITY_PATCHING, "security.ubuntuNobleKernelMinimum.status": _SECURITY_PATCHING, "security.fragnesia.status": _SECURITY_PATCHING, "security.badEpoll.status": _SECURITY_PATCHING, diff --git a/cmax/minimum_refresh.py b/cmax/minimum_refresh.py index 69f1581..1834ada 100644 --- a/cmax/minimum_refresh.py +++ b/cmax/minimum_refresh.py @@ -232,6 +232,28 @@ def validate_bulletin_lists() -> None: {"key": "linuxJanuscape", "cve": "CVE-2026-53359", "package": "linux"}, ) +# Container-relevant GA kernel CVEs that the audit grades per CVE on Ubuntu +# 22.04 (5.15) and 24.04 (6.8). Ubuntu can track either CVE as `needed` or +# `pending` before it releases a fix. That state is a vulnerability finding, so +# `allowUnfixed` lets the entry publish without a fixed version. +UBUNTU_KERNEL_CVE_PACKAGES: tuple[dict[str, Any], ...] = ( + { + "key": "linuxOpenvswitchNestedActions", + "cve": "CVE-2026-64531", + "package": "linux", + "abi": True, + "allowUnfixed": True, + }, + { + "key": "linuxCgroupWriteback", + "cve": "CVE-2026-64378", + "package": "linux", + "abi": True, + "allowUnfixed": True, + }, +) +UBUNTU_PACKAGES += UBUNTU_KERNEL_CVE_PACKAGES + # Ubuntu 26.04 (resolute) GA kernel fixes the audit grades by source package. UBUNTU_RESOLUTE_PACKAGES: tuple[dict[str, Any], ...] = ( {"key": "linuxPwnescape", "cve": "CVE-2026-99999", "package": "linux"}, @@ -1901,6 +1923,8 @@ def resolve(spec: dict) -> tuple[str, dict]: abi = kernel_abi(entry.get("fixed")) if abi is not None: entry["abi"] = abi + if spec.get("allowUnfixed"): + entry["allowUnfixed"] = True return spec["key"], entry # Independent package lookups must not add every slow Ubuntu response to @@ -2739,6 +2763,7 @@ def build_minimums( "package": "linux-hwe-6.8"}, {"key": "linuxPwnescape", "cve": "CVE-2026-99999", "package": "linux-hwe-6.8"}, + *UBUNTU_KERNEL_CVE_PACKAGES, ), package_policy=(), fetch=fetch, @@ -2965,6 +2990,25 @@ def verify(doc: dict, existing: dict | None = None) -> None: problems.append(f"{name}.{key}: released PwnEscape fix disappeared") if state == "needed" and not old_entry.get("fixed"): continue + # A per-CVE kernel entry graded from the tracker state. `needed` + # and `pending` are valid findings without a fixed version, but a + # released fix must never regress. + if entry.get("allowUnfixed") is True and not pwnescape: + state = entry.get("status") + if old_entry.get("fixed") and not entry.get("fixed"): + problems.append( + f"{name}.{key}: released fix {old_entry.get('fixed')} for " + f"{entry.get('cve')} disappeared" + ) + continue + if state in {"needed", "pending"} and not entry.get("fixed"): + continue + if state not in {"needed", "pending", "released"}: + problems.append( + f"{name}.{key}: Ubuntu tracker status for {entry.get('cve')} " + f"is unverified ({state!r})" + ) + continue if entry.get("fixed"): continue was_fixed = old_entry.get("fixed") diff --git a/cmax/scripts/1-audit/audit-common.sh b/cmax/scripts/1-audit/audit-common.sh index f306caf..60e24d2 100644 --- a/cmax/scripts/1-audit/audit-common.sh +++ b/cmax/scripts/1-audit/audit-common.sh @@ -133,6 +133,75 @@ minimum_version_grace() { fi } +# grade_ubuntu_kernel_cves - grade the running Ubuntu general-availability +# kernel against the exact tracker state for the two container-relevant CVEs. +# A tracker status of needed or pending is vulnerable because Ubuntu has not +# released a supported fix. A released status compares the running ABI with the +# generated fixed ABI. Other Ubuntu kernel trains use different source packages +# and stay outside this check. +grade_ubuntu_kernel_cves() { + local os_id="${1:-unknown}" release="${2:-unknown}" running="${3:-unknown}" + local component="" expected_prefix="" key="" cve="" tracker="" fixed_abi="" + local running_abi="" vulnerable="" grace="" grace_periods="" + local unknown_count=0 checked=0 + UBUNTU_KERNEL_CVE_STATUS="not-applicable" + UBUNTU_KERNEL_VULNERABLE_CVES_JSON="[]" + UBUNTU_KERNEL_CVE_GRACE_PERIODS_JSON="[]" + + [[ "$os_id" == ubuntu ]] || return 0 + case "$release" in + 22.04) component="ubuntuJammy"; expected_prefix="5.15.0-" ;; + 24.04) component="ubuntuNoble"; expected_prefix="6.8.0-" ;; + *) return 0 ;; + esac + [[ "$running" == "${expected_prefix}"* ]] || return 0 + case "$running" in + *-generic|*-generic-64k) ;; + *) return 0 ;; + esac + running_abi=$(printf '%s\n' "$running" | sed -nE 's/^[0-9]+\.[0-9]+\.0-([0-9]+).*/\1/p') + + for key in linuxOpenvswitchNestedActions linuxCgroupWriteback; do + cve=$(minimum_version "components.${component}.packages.${key}.cve") + tracker=$(minimum_version "components.${component}.packages.${key}.status") + fixed_abi=$(minimum_version "components.${component}.packages.${key}.abi") + case "$(printf '%s' "$tracker" | tr '[:upper:]' '[:lower:]')" in + needed|pending) + vulnerable="${vulnerable:+${vulnerable},}${cve}" + checked=$((checked + 1)) + ;; + released) + checked=$((checked + 1)) + if [[ "$running_abi" =~ ^[0-9]+$ && "$fixed_abi" =~ ^[0-9]+$ ]]; then + if (( running_abi < fixed_abi )); then + if grace=$(minimum_version_grace "$component" "$key"); then + grace_periods="${grace_periods:+${grace_periods},}{\"cve\":\"${cve}\",\"policy\":${grace}}" + else + vulnerable="${vulnerable:+${vulnerable},}${cve}" + fi + fi + else + unknown_count=$((unknown_count + 1)) + fi + ;; + not-affected|dne) + checked=$((checked + 1)) + ;; + *) unknown_count=$((unknown_count + 1)) ;; + esac + done + + UBUNTU_KERNEL_CVE_GRACE_PERIODS_JSON="[${grace_periods}]" + if [[ -n "$vulnerable" ]]; then + UBUNTU_KERNEL_CVE_STATUS="fail" + UBUNTU_KERNEL_VULNERABLE_CVES_JSON="[\"${vulnerable//,/\",\"}\"]" + elif (( unknown_count > 0 || checked == 0 )); then + UBUNTU_KERNEL_CVE_STATUS="unknown" + else + UBUNTU_KERNEL_CVE_STATUS="pass" + fi +} + # build_security_advisory_json - emit the Ubuntu Noble advisory members that # every collector reports: fragnesia, januscape, qemuCve20243446, and vmscape. # @@ -1286,6 +1355,8 @@ build_audit_json() { local security_advisory_json local security_version_audit_json="${SECURITY_VERSION_AUDIT_JSON:-}" januscape_exposed_json=$(json_bool_or_unknown "${WORKER_JANUSCAPE_EXPOSED:-}") + grade_ubuntu_kernel_cves "${WORKER_OS_ID:-unknown}" \ + "${WORKER_OS_VERSION_ID:-unknown}" "${WORKER_GUEST_KERNEL_RUNNING:-unknown}" if [[ "${AUDIT_TYPE:-}" == "standalone" \ && "${WORKER_NVLINK_TOPOLOGY_CHECKED:-false}" == "true" ]]; then nvlink_topology_coverage_complete=true @@ -1613,7 +1684,10 @@ build_audit_json() { "running": "${WORKER_GUEST_KERNEL_RUNNING:-unknown}", "newestInstalled": "${WORKER_GUEST_KERNEL_NEWEST_INSTALLED:-unknown}", "newerInstalled": ${WORKER_GUEST_KERNEL_NEWER_INSTALLED:-false}, - "rebootRequired": ${WORKER_GUEST_REBOOT_REQUIRED:-false} + "rebootRequired": ${WORKER_GUEST_REBOOT_REQUIRED:-false}, + "ubuntuCveStatus": "${UBUNTU_KERNEL_CVE_STATUS:-unknown}", + "ubuntuVulnerableCves": ${UBUNTU_KERNEL_VULNERABLE_CVES_JSON:-[]}, + "ubuntuCveGracePeriods": ${UBUNTU_KERNEL_CVE_GRACE_PERIODS_JSON:-[]} }, ${security_advisory_json} "nvidiaMay2026": { diff --git a/cmax/scripts/1-audit/audit_findings.py b/cmax/scripts/1-audit/audit_findings.py index c3ed836..cd44f49 100644 --- a/cmax/scripts/1-audit/audit_findings.py +++ b/cmax/scripts/1-audit/audit_findings.py @@ -890,6 +890,12 @@ def _tenant_pod_guidance( CONFIG, _status_is("unknown"), ), + Rule( + "security.guestKernel.ubuntuCveStatus", + "Running Ubuntu kernel is vulnerable under the distribution CVE tracker", + VERSION, + _status_is("fail"), + ), Rule( "security.fragnesia.status", "Running kernel is exposed to Fragnesia and Dirty Frag", @@ -1584,6 +1590,7 @@ def classify_suppression(key: str, audit: dict[str, Any]) -> tuple[str, str]: "securityVersions.dpuHostIsolation.status": "security-dpu-host-isolation", "security.januscape.status": "security-januscape", "security.guestKernel.newerInstalled": "guest-kernel-current", + "security.guestKernel.ubuntuCveStatus": "guest-kernel-current", "security.ubuntuNobleKernelMinimum.status": "ubuntu-kernel-minimum-version", "security.fragnesia.status": "ubuntu-kernel-fragnesia-dirty-frag", # CLI-only until the website catalog publishes a dedicated Bad Epoll row. @@ -2088,6 +2095,21 @@ def detect_findings(values: dict[str, Any], *, source: str = "") -> list[Finding detected, recommendation, cves, advisories = _finding_guidance( audit, finding_rule, guidance_value ) + if rule.key == "security.guestKernel.ubuntuCveStatus": + # The finding names only the CVEs this kernel is vulnerable to. + vulnerable_cves = nested_get( + audit, "security", "guestKernel", "ubuntuVulnerableCves" + ) + cves = ( + tuple( + cve + for cve in vulnerable_cves + if isinstance(cve, str) + and re.fullmatch(r"CVE-\d{4}-\d+", cve) + ) + if isinstance(vulnerable_cves, list) + else () + ) findings.append( Finding( title=finding_rule.title, diff --git a/cmax/scripts/1-audit/cluster-audit-k8s.sh b/cmax/scripts/1-audit/cluster-audit-k8s.sh index e8ecaf1..791bfbe 100755 --- a/cmax/scripts/1-audit/cluster-audit-k8s.sh +++ b/cmax/scripts/1-audit/cluster-audit-k8s.sh @@ -3569,6 +3569,8 @@ SECURITY_ADVISORY_JSON=$(build_security_advisory_json \ --januscape-status "${HP_JANUSCAPE_STATUS:-unknown}" \ --qemu-status "${HP_QEMU_3446_STATUS:-unknown}" \ --vmscape-status "${HP_VMSCAPE_STATUS:-unknown}") +grade_ubuntu_kernel_cves "${HP_BAD_EPOLL_OS:-unknown}" "${HP_BAD_EPOLL_RELEASE:-unknown}" \ + "${HP_GUEST_KERNEL_RUNNING:-unknown}" JSON_OUTPUT=$(cat < tuple[SecurityReference, ...]: return tuple(SecurityReference(label, url) for label, url in items) +_GUEST_KERNEL_UBUNTU_TITLE = "Active kernel version and Ubuntu CVE status" +_GUEST_KERNEL_UBUNTU_IMPORTANCE = ( + "Installed kernel fixes provide no protection until the fixed kernel is running, " + "and the newest installed kernel can remain vulnerable." +) +_GUEST_KERNEL_UBUNTU_REMEDIATION = ( + "Install a supported Ubuntu kernel fix, reboot into it, and confirm the running " + "release after startup." +) +_GUEST_KERNEL_UBUNTU_REFERENCES = _references( + ( + "Ubuntu security updates", + "https://documentation.ubuntu.com/security/security-updates/", + ), + ("Ubuntu kernel update guidance", "https://ubuntu.com/security/notices"), + ("CVE-2026-64531", "https://ubuntu.com/security/CVE-2026-64531"), + ("CVE-2026-64378", "https://ubuntu.com/security/CVE-2026-64378"), +) + + def _command_ok(command: list[str], *, timeout: float = 5.0) -> bool: try: return ( @@ -354,6 +374,18 @@ def _display(value: Any) -> str: def _grace_details(value: Any) -> tuple[bool, str]: + if isinstance(value, list): + # Per-CVE grace records, such as security.guestKernel.ubuntuCveGracePeriods. + policies = [ + item.get("policy") + for item in value + if isinstance(item, dict) and isinstance(item.get("policy"), dict) + ] + active = [policy for policy in policies if policy.get("active") is True] + if not active: + return False, "" + notes = [str(policy.get("message") or "").strip() for policy in active] + return True, " ".join(dict.fromkeys(note for note in notes if note)) if not isinstance(value, dict): return False, "" grace = value.get("gracePeriod") @@ -560,6 +592,23 @@ def _guest_kernel(audit: dict[str, Any]) -> tuple[str, str, str]: f"observed version {running}; minimum version not applicable; " f"newest installed version {newest}" ) + ubuntu_cve_status = str(kernel.get("ubuntuCveStatus") or "").lower() + vulnerable_cves = kernel.get("ubuntuVulnerableCves") + if isinstance(vulnerable_cves, list) and vulnerable_cves: + observed += "; vulnerable to " + ", ".join(str(cve) for cve in vulnerable_cves) + grace_periods = kernel.get("ubuntuCveGracePeriods") + if isinstance(grace_periods, list) and grace_periods: + grace_cves = [ + str(item.get("cve")) for item in grace_periods if isinstance(item, dict) + ] + if grace_cves: + observed += "; policy grace active for " + ", ".join(grace_cves) + if ubuntu_cve_status == "fail": + return ( + CRITICAL, + observed, + "Ubuntu identifies the running kernel as vulnerable to a tracked container-relevant CVE.", + ) newer = kernel.get("newerInstalled") if newer is True or str(newer).lower() == "true": return ( @@ -579,6 +628,18 @@ def _guest_kernel(audit: dict[str, Any]) -> tuple[str, str, str]: "The installed kernel inventory was unavailable, so a pending security reboot cannot be ruled out.", ) if newer is False or str(newer).lower() == "false": + if ubuntu_cve_status == "unknown": + return ( + WARNING, + observed, + "The Ubuntu CVE status or fixed kernel minimum could not be verified.", + ) + if isinstance(grace_periods, list) and grace_periods: + return ( + PASS, + observed, + "The running kernel is within the active Ubuntu bulletin grace period.", + ) return PASS, observed, "The audit did not find a newer installed kernel that is waiting for a reboot." return ( WARNING, @@ -2061,10 +2122,22 @@ def evaluate_security(values: dict[str, Any]) -> list[SecurityCheck]: "connectx-firmware": "securityVersions.connectxFirmware", "virtio-net-bluefield": "securityVersions.virtioNetBluefield", "fragnesia": "security.fragnesia", + "guest-kernel": "security.guestKernel.ubuntuCveGracePeriods", } for spec in CHECK_SPECS: status, observed, assessment = spec.evaluate(audit) + title = spec.title + importance = spec.importance remediation, references = _minimum_bound_details(spec) + if spec.id == "guest-kernel": + ubuntu_cve_status = str( + _get(audit, "security.guestKernel.ubuntuCveStatus") or "" + ).lower() + if ubuntu_cve_status in {"fail", "pass", "unknown"}: + title = _GUEST_KERNEL_UBUNTU_TITLE + importance = _GUEST_KERNEL_UBUNTU_IMPORTANCE + remediation = _GUEST_KERNEL_UBUNTU_REMEDIATION + references = _GUEST_KERNEL_UBUNTU_REFERENCES minimum_url = minimum_links.security_check_url(spec.id) if minimum_url: references = ( @@ -2095,10 +2168,10 @@ def evaluate_security(values: dict[str, Any]) -> list[SecurityCheck]: checks.append( SecurityCheck( spec.id, - spec.title, + title, status, observed, - spec.importance, + importance, assessment, remediation, documentation, diff --git a/tests/audit/fixtures/minimum_refresh/ubuntu-osv.json b/tests/audit/fixtures/minimum_refresh/ubuntu-osv.json index d273eb9..54dccee 100644 --- a/tests/audit/fixtures/minimum_refresh/ubuntu-osv.json +++ b/tests/audit/fixtures/minimum_refresh/ubuntu-osv.json @@ -281,6 +281,110 @@ ] } ] + }, + "CVE-2026-64531": { + "id": "UBUNTU-CVE-2026-64531", + "upstream": [ + "CVE-2026-64531" + ], + "related": [ + "USN-8643-1", + "USN-8658-1" + ], + "published": "2026-08-11T15:16:00Z", + "severity": [ + { + "type": "Ubuntu", + "score": "high" + } + ], + "affected": [ + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:22.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.15.0-190.200" + } + ] + } + ] + }, + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:24.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "6.8.0-138.138" + } + ] + } + ] + } + ] + }, + "CVE-2026-64378": { + "id": "UBUNTU-CVE-2026-64378", + "upstream": [ + "CVE-2026-64378" + ], + "published": "2026-08-11T15:16:00Z", + "severity": [ + { + "type": "Ubuntu", + "score": "low" + } + ], + "affected": [ + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:22.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + } + ] + } + ] + }, + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:24.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + } + ] + } + ] + } + ] } }, "notices": { @@ -407,6 +511,56 @@ ] } ] + }, + "USN-8643-1": { + "id": "USN-8643-1", + "published": "2026-08-18T18:08:07Z", + "affected": [ + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:24.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "6.8.0-138.138" + } + ] + } + ] + } + ] + }, + "USN-8658-1": { + "id": "USN-8658-1", + "published": "2026-08-25T14:02:11Z", + "affected": [ + { + "package": { + "name": "linux", + "ecosystem": "Ubuntu:22.04:LTS" + }, + "ranges": [ + { + "type": "ECOSYSTEM", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.15.0-190.200" + } + ] + } + ] + } + ] } } } diff --git a/tests/audit/fixtures/minimum_refresh/ubuntu-security-api.json b/tests/audit/fixtures/minimum_refresh/ubuntu-security-api.json index 02fd4b1..c9904ee 100644 --- a/tests/audit/fixtures/minimum_refresh/ubuntu-security-api.json +++ b/tests/audit/fixtures/minimum_refresh/ubuntu-security-api.json @@ -234,6 +234,58 @@ ] } ] + }, + "CVE-2026-64531": { + "id": "CVE-2026-64531", + "priority": "high", + "notices": [ + { + "id": "USN-8643-1", + "published": "2026-08-18T18:08:07.783247" + }, + { + "id": "USN-8658-1", + "published": "2026-08-25T14:02:11.120334" + } + ], + "packages": [ + { + "name": "linux", + "statuses": [ + { + "release_codename": "jammy", + "status": "released", + "description": "5.15.0-190.200" + }, + { + "release_codename": "noble", + "status": "released", + "description": "6.8.0-138.138" + } + ] + } + ] + }, + "CVE-2026-64378": { + "id": "CVE-2026-64378", + "priority": "low", + "packages": [ + { + "name": "linux", + "statuses": [ + { + "release_codename": "jammy", + "status": "needed", + "description": "" + }, + { + "release_codename": "noble", + "status": "needed", + "description": "" + } + ] + } + ] } }, "notices": { diff --git a/tests/audit/test_audit_findings.py b/tests/audit/test_audit_findings.py index a81c79a..c09009e 100644 --- a/tests/audit/test_audit_findings.py +++ b/tests/audit/test_audit_findings.py @@ -785,6 +785,46 @@ def test_newer_installed_kernel_requires_reboot(self) -> None: audit_findings.VERSION, ) + def test_ubuntu_tracker_vulnerable_kernel_is_a_version_finding(self) -> None: + findings = audit_findings.detect_findings( + { + "audit_data": { + "security": { + "guestKernel": { + "running": "5.15.0-185-generic", + "newestInstalled": "5.15.0-185-generic", + "newerInstalled": False, + "ubuntuCveStatus": "fail", + "ubuntuVulnerableCves": [ + "CVE-2026-64531", + "CVE-2026-64378", + ], + } + } + } + } + ) + finding = by_key(findings)["security.guestKernel.ubuntuCveStatus"] + self.assertEqual(finding.severity, audit_findings.VERSION) + self.assertEqual(finding.cves, ("CVE-2026-64531", "CVE-2026-64378")) + + def test_ubuntu_tracker_finding_lists_only_vulnerable_cves(self) -> None: + findings = audit_findings.detect_findings( + { + "audit_data": { + "security": { + "guestKernel": { + "newerInstalled": False, + "ubuntuCveStatus": "fail", + "ubuntuVulnerableCves": ["CVE-2026-64378", "not-a-cve"], + } + } + } + } + ) + finding = by_key(findings)["security.guestKernel.ubuntuCveStatus"] + self.assertEqual(finding.cves, ("CVE-2026-64378",)) + def test_fragnesia_exposed_kernel_is_a_version_finding(self) -> None: findings = audit_findings.detect_findings( {"audit_data": {"security": {"fragnesia": {"status": "fail"}}}} diff --git a/tests/audit/test_minimum_refresh.py b/tests/audit/test_minimum_refresh.py index b41c0a6..d95e153 100644 --- a/tests/audit/test_minimum_refresh.py +++ b/tests/audit/test_minimum_refresh.py @@ -590,6 +590,43 @@ def test_pwnescape_refresh_rejects_unknown_states_and_loss_of_a_released_fix(sel with self.assertRaisesRegex(fr.MinimumRefreshError, "released PwnEscape fix disappeared"): fr.verify(baseline(), old) + def test_ga_kernel_cves_are_published_for_jammy_and_noble(self) -> None: + data = fr.build_minimums(generated=GENERATED, existing={}, fetch=self.stub) + for component, fixed, abi in ( + ("ubuntuNoble", "6.8.0-138.138", 138), + ("ubuntuJammy", "5.15.0-190.200", 190), + ): + packages = data["components"][component]["packages"] + ovs = packages["linuxOpenvswitchNestedActions"] + self.assertEqual(ovs["cve"], "CVE-2026-64531") + self.assertEqual(ovs["package"], "linux") + self.assertEqual(ovs["status"], "released") + self.assertEqual(ovs["fixed"], fixed) + self.assertEqual(ovs["abi"], abi) + self.assertIs(ovs["allowUnfixed"], True) + cgroup = packages["linuxCgroupWriteback"] + self.assertEqual(cgroup["cve"], "CVE-2026-64378") + self.assertEqual(cgroup["status"], "needed") + self.assertFalse(cgroup.get("fixed")) + self.assertIs(cgroup["allowUnfixed"], True) + fr.verify(data, data) + + def test_ga_kernel_cve_refresh_rejects_unknown_states_and_loss_of_a_fix(self) -> None: + old = baseline() + for state in ("needs-triage", "ignored", "not-found"): + data = baseline() + entry = data["components"]["ubuntuJammy"]["packages"]["linuxCgroupWriteback"] + entry["status"] = state + with self.subTest(state=state), self.assertRaisesRegex( + fr.MinimumRefreshError, "linuxCgroupWriteback" + ): + fr.verify(data, old) + data = baseline() + entry = data["components"]["ubuntuNoble"]["packages"]["linuxOpenvswitchNestedActions"] + entry.update(status="needed", fixed="") + with self.assertRaisesRegex(fr.MinimumRefreshError, "released fix 6.8.0-138.138"): + fr.verify(data, old) + def test_ubuntu_noble_packages(self) -> None: osv_url = fr.ubuntu_osv_urls("CVE-2024-3446")[0] self.stub.json_by_url[osv_url]["related"] = [ @@ -628,6 +665,7 @@ def test_ubuntu_noble_packages(self) -> None: f"{fr.UBUNTU_CVE_URL}CVE-2026-46242.json", f"{fr.UBUNTU_CVE_URL}CVE-2026-46300.json", f"{fr.UBUNTU_CVE_URL}CVE-2026-53359.json", + f"{fr.UBUNTU_CVE_URL}CVE-2026-64378.json", ]) def test_ubuntu_notice_discovery_fails_closed_on_incomplete_pagination(self) -> None: diff --git a/tests/audit/test_minimum_versions_shell.py b/tests/audit/test_minimum_versions_shell.py index b4c688f..b5fc617 100644 --- a/tests/audit/test_minimum_versions_shell.py +++ b/tests/audit/test_minimum_versions_shell.py @@ -195,6 +195,20 @@ def minimum_table( "kind": "distroPackages", "release": "noble", "packages": { + "linuxOpenvswitchNestedActions": { + "abi": 138, + "allowUnfixed": True, + "cve": "CVE-2026-64531", + "fixed": "6.8.0-138.138", + "package": "linux", + "status": "released", + }, + "linuxCgroupWriteback": { + "allowUnfixed": True, + "cve": "CVE-2026-64378", + "package": "linux", + "status": "needed", + }, "linux": { "abi": linux_abi, "fixed": linux_fixed, @@ -221,11 +235,115 @@ def minimum_table( "status": "released", }, }, - } + }, + "ubuntuJammy": { + "kind": "distroPackages", + "release": "jammy", + "packages": { + "linuxOpenvswitchNestedActions": { + "abi": 190, + "allowUnfixed": True, + "cve": "CVE-2026-64531", + "package": "linux", + "status": "pending", + }, + "linuxCgroupWriteback": { + "allowUnfixed": True, + "cve": "CVE-2026-64378", + "package": "linux", + "status": "needed", + }, + }, + }, }, } +class UbuntuKernelCveTests(TempDirMixin): + """grade_ubuntu_kernel_cves grades the two tracked GA-kernel CVEs.""" + + def grade(self, table: dict, release: str, kernel: str) -> tuple[str, ...]: + path = self.write_minimum_table(table) + run = bashtest.run_bash( + f'source "{AUDIT_COMMON}"\n' + f'grade_ubuntu_kernel_cves ubuntu "{release}" "{kernel}"\n' + 'printf "%s\\n%s\\n%s\\n" "$UBUNTU_KERNEL_CVE_STATUS" ' + '"$UBUNTU_KERNEL_VULNERABLE_CVES_JSON" ' + '"$UBUNTU_KERNEL_CVE_GRACE_PERIODS_JSON"\n', + env={"CLUSTERMAX_MINIMUM_VERSIONS": str(path)}, + ) + self.assertEqual(run.returncode, 0, run.stderr) + return tuple(run.stdout.splitlines()) + + def test_jammy_kernel_fails_both_unreleased_cves(self) -> None: + status, cves, grace = self.grade(minimum_table(), "22.04", "5.15.0-185-generic") + self.assertEqual(status, "fail") + self.assertEqual(json.loads(cves), ["CVE-2026-64531", "CVE-2026-64378"]) + self.assertEqual(json.loads(grace), []) + + def test_noble_kernel_fails_only_the_unfixed_cve_at_the_released_abi(self) -> None: + status, cves, _ = self.grade(minimum_table(), "24.04", "6.8.0-138-generic") + self.assertEqual(status, "fail") + self.assertEqual(json.loads(cves), ["CVE-2026-64378"]) + + def test_noble_kernel_below_the_released_abi_fails_that_cve(self) -> None: + status, cves, _ = self.grade(minimum_table(), "24.04", "6.8.0-137-generic-64k") + self.assertEqual(status, "fail") + self.assertEqual(json.loads(cves), ["CVE-2026-64531", "CVE-2026-64378"]) + + def test_released_fixes_pass_at_the_generated_abi(self) -> None: + table = minimum_table() + packages = table["components"]["ubuntuJammy"]["packages"] + packages["linuxOpenvswitchNestedActions"].update(status="released", abi=190) + packages["linuxCgroupWriteback"].update(status="released", abi=190) + self.assertEqual( + self.grade(table, "22.04", "5.15.0-190-generic"), ("pass", "[]", "[]") + ) + + def test_released_fix_observes_the_active_bulletin_grace_period(self) -> None: + table = minimum_table() + packages = table["components"]["ubuntuJammy"]["packages"] + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + packages["linuxOpenvswitchNestedActions"].update( + status="released", + abi=190, + fixed="5.15.0-190.200", + released=now, + fixAvailability={ + "status": "confirmed", + "available": now, + "bulletinReleased": now, + }, + ) + packages["linuxCgroupWriteback"].update(status="not-affected") + + status, cves, grace = self.grade(table, "22.04", "5.15.0-185-generic") + + self.assertEqual((status, cves), ("pass", "[]")) + self.assertEqual(json.loads(grace)[0]["cve"], "CVE-2026-64531") + self.assertIs(json.loads(grace)[0]["policy"]["active"], True) + + def test_missing_tracker_rows_are_unknown(self) -> None: + table = minimum_table() + del table["components"]["ubuntuJammy"] + self.assertEqual( + self.grade(table, "22.04", "5.15.0-185-generic"), ("unknown", "[]", "[]") + ) + + def test_other_kernel_trains_and_flavors_are_not_applicable(self) -> None: + for release, kernel in ( + ("22.04", "5.15.0-1084-aws"), + ("24.04", "6.8.0-1044-nvidia"), + ("22.04", "6.8.0-60-generic"), + ("20.04", "5.4.0-200-generic"), + ): + with self.subTest(kernel=kernel): + self.assertEqual( + self.grade(minimum_table(), release, kernel), + ("not-applicable", "[]", "[]"), + ) + + class FragnesiaAbiMinimumTests(TempDirMixin): def check(self, kernel: str, **env: str) -> dict[str, str]: run, facts = fragnesia_check(kernel, **env) diff --git a/tests/audit/test_security_cli.py b/tests/audit/test_security_cli.py index 02939ff..b0bc625 100644 --- a/tests/audit/test_security_cli.py +++ b/tests/audit/test_security_cli.py @@ -570,6 +570,97 @@ def test_connectx_report_warns_for_incomplete_inventory(self) -> None: if excluded is not None: self.assertNotIn(excluded, check.observed) + def test_ubuntu_tracker_vulnerability_fails_the_active_kernel_check(self) -> None: + values = security_values() + guest_kernel = values["audit_data"]["security"]["guestKernel"] + guest_kernel.update( + running="5.15.0-185-generic", + newestInstalled="5.15.0-185-generic", + newerInstalled=False, + ubuntuCveStatus="fail", + ubuntuVulnerableCves=["CVE-2026-64531", "CVE-2026-64378"], + ) + check = next( + check + for check in security.evaluate_security(values) + if check.id == "guest-kernel" + ) + self.assertEqual(check.status, security.CRITICAL) + self.assertIn("CVE-2026-64531, CVE-2026-64378", check.observed) + self.assertIn("Ubuntu CVE status", check.title) + self.assertIn("supported Ubuntu kernel fix", check.remediation) + self.assertIn( + "CVE-2026-64531", {reference.label for reference in check.references} + ) + + def test_unknown_ubuntu_tracker_status_never_passes(self) -> None: + values = security_values() + values["audit_data"]["security"]["guestKernel"].update( + running="6.8.0-138-generic", + newestInstalled="6.8.0-138-generic", + newerInstalled=False, + ubuntuCveStatus="unknown", + ubuntuVulnerableCves=[], + ) + check = next( + check + for check in security.evaluate_security(values) + if check.id == "guest-kernel" + ) + self.assertEqual(check.status, security.WARNING) + self.assertIn("Ubuntu CVE status", check.assessment) + + def test_non_ubuntu_kernel_keeps_the_generic_guest_kernel_check(self) -> None: + values = security_values() + values["audit_data"]["security"]["guestKernel"].update( + running="5.14.0-570.12.1.el9_6.x86_64", + newestInstalled="5.14.0-570.18.1.el9_6.x86_64", + newerInstalled=True, + ubuntuCveStatus="not-applicable", + ubuntuVulnerableCves=[], + ) + check = next( + check + for check in security.evaluate_security(values) + if check.id == "guest-kernel" + ) + self.assertEqual(check.status, security.CRITICAL) + self.assertEqual(check.title, "Active kernel version") + self.assertNotIn( + "CVE-2026-64531", {reference.label for reference in check.references} + ) + + def test_guest_kernel_grace_pass_stays_visible_in_the_default_report(self) -> None: + values = security_values() + values["audit_data"]["security"]["guestKernel"].update( + running="6.8.0-137-generic", + newestInstalled="6.8.0-137-generic", + newerInstalled=False, + ubuntuCveStatus="pass", + ubuntuVulnerableCves=[], + ubuntuCveGracePeriods=[ + { + "cve": "CVE-2026-64531", + "policy": { + "active": True, + "message": "(passes as fix became available within past 3 days)", + }, + } + ], + ) + checks = security.evaluate_security(values) + check = next(check for check in checks if check.id == "guest-kernel") + report = security.format_report( + checks, + security.SecurityTarget("standalone", "vm"), + Path("/tmp/report"), + ) + self.assertEqual(check.status, security.PASS) + self.assertIs(check.grace_period, True) + self.assertIn("active Ubuntu bulletin grace period", check.assessment) + self.assertIn("fix became available within past 3 days", check.grace_note) + self.assertIn("[guest-kernel]", report) + def test_unknown_guest_kernel_inventory_never_passes(self) -> None: for missing_field in ("running", "newestInstalled"): with self.subTest(missing_field=missing_field):