mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-14 06:37:25 +00:00
A truncated chunked OSV download raises http.client.IncompleteRead, an HTTPException rather than an OSError, so it skipped the .part cleanup and the error line; the handler now catches both. The runtime check's warn summary no longer claims the mirror scan tracks every warning, since host findings and images outside the mirror never reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1766 lines
56 KiB
Python
Executable File
1766 lines
56 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# nocheck: file-size - sys-service installs this one file on every node, which runs it without the repository, so the engine pipeline cannot move into importable modules
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import urllib.parse
|
||
import uuid
|
||
from collections import Counter
|
||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
PROBE_MARKER = "INFINITO-RUNTIME-PROBE"
|
||
HARDENED_SETTINGS = ("expose_php", "session.upload_progress.enabled")
|
||
PHP_PROBE = (
|
||
f'echo "\\n{PROBE_MARKER}|", ini_get("expose_php"), "|", '
|
||
'ini_get("session.upload_progress.enabled"), "\\n";'
|
||
)
|
||
NO_BINARY_EXIT_CODES = frozenset({126, 127})
|
||
EXEC_TIMEOUT_SECONDS = 60
|
||
ENABLED_VALUES = frozenset({"1", "on", "yes", "true"})
|
||
|
||
SCANNERS = ("trivy", "grype", "osv")
|
||
SBOM_BUILDER = "syft"
|
||
ENGINES = (*SCANNERS, SBOM_BUILDER)
|
||
SBOM_READERS = frozenset({"grype", "osv"})
|
||
RUNTIME_ENGINE = "grype"
|
||
RUNTIME_ARTIFACT_TYPE = "binary"
|
||
TARGET_KINDS = ("host", "container")
|
||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "NEGLIGIBLE", "NONE", "UNKNOWN")
|
||
ACTIONS = ("fail", "warn")
|
||
SUPPRESSED = "suppressed"
|
||
VERDICTS = (*ACTIONS, SUPPRESSED)
|
||
CLEAN = "ok"
|
||
METRIC = "infinito_hlth_runtime"
|
||
METRIC_LABELS = ("target", "kind", "image", "severity", "scanner", "verdict")
|
||
FINDINGS_LOG = "findings.jsonl"
|
||
REPORT = "report.json"
|
||
CVE_PATTERN = re.compile(r"^CVE-\d{4}-\d{4,}$")
|
||
OSV_DB_LAYOUT = "osv-scalibr"
|
||
OSV_MISSING_DB = re.compile(r"could not load db for (.+?) ecosystem")
|
||
SUCCESS = frozenset({0})
|
||
OSV_ACCEPTED_EXIT_CODES = frozenset({0, 1, 128})
|
||
OSV_SOURCE_PURL_TYPES = ("deb", "apk")
|
||
UPSTREAM_QUALIFIER = re.compile(r"([?&])upstream=([^&#]+)")
|
||
HOST_ROOT = "/host"
|
||
WORK = "/work"
|
||
SBOM = "/sbom"
|
||
CACHE = "/cache"
|
||
DOCKER_SOCKET = "/var/run/docker.sock"
|
||
JOB_TIMEOUT_SECONDS = 4 * 3600
|
||
DB_ATTEMPTS = 3
|
||
DB_RETRY_SECONDS = 30
|
||
DB_MARKERS = {
|
||
"trivy": ("db/trivy.db", "java-db/trivy-java.db"),
|
||
"grype": ("6/vulnerability.db",),
|
||
"osv": (OSV_DB_LAYOUT,),
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Container:
|
||
id: str
|
||
name: str
|
||
stack: str | None
|
||
service: str | None
|
||
image: str
|
||
image_name: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PhpProbe:
|
||
settings: dict
|
||
|
||
|
||
class ProbeError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class ConfigError(ValueError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScanConfig:
|
||
"""What the vulnerability scan covers and when it fails.
|
||
|
||
Args:
|
||
targets: subset of :data:`TARGET_KINDS`.
|
||
engines: subset of :data:`ENGINES` holding at least one scanner.
|
||
severity: ``{severity: action}`` with each action in :data:`ACTIONS`;
|
||
a severity it does not name is below the threshold.
|
||
fixed_only: report only findings with a fixed version.
|
||
workers: parallel engine runs.
|
||
db_update: download and refresh the databases; ``False`` reads
|
||
databases that were provided and fails when one is missing.
|
||
"""
|
||
|
||
targets: tuple[str, ...]
|
||
engines: tuple[str, ...]
|
||
severity: Mapping[str, str]
|
||
fixed_only: bool
|
||
workers: int
|
||
db_update: bool
|
||
|
||
def scanners(self) -> tuple[str, ...]:
|
||
return tuple(engine for engine in SCANNERS if engine in self.engines)
|
||
|
||
def reads_sbom(self, engine: str) -> bool:
|
||
return engine in SBOM_READERS and SBOM_BUILDER in self.engines
|
||
|
||
def runtime_pass(self) -> bool:
|
||
"""Whether Grype scans the source-built runtimes Syft found.
|
||
|
||
Only Grype matches a runtime compiled from source, such as the PHP
|
||
of the official ``php`` images, through its CPE; Trivy and OSV do
|
||
not see it. Without Grype in the list, Grype still scans the
|
||
binary-classified packages of every SBOM.
|
||
"""
|
||
return RUNTIME_ENGINE not in self.engines and SBOM_BUILDER in self.engines
|
||
|
||
def databases(self) -> tuple[str, ...]:
|
||
"""The scanners whose database the scan needs."""
|
||
needed = set(self.scanners()) | (
|
||
{RUNTIME_ENGINE} if self.runtime_pass() else set()
|
||
)
|
||
return tuple(engine for engine in SCANNERS if engine in needed)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Target:
|
||
"""One thing a scanner reads.
|
||
|
||
Args:
|
||
key: file-name-safe identity.
|
||
kind: ``host`` or ``container``.
|
||
ref: the image id, or :data:`HOST_ROOT`.
|
||
label: the image name or the host name.
|
||
owners: ``(stack, service)`` of every container running the image.
|
||
owner_tags: ``((stack, service), tag)`` pairs, the image tag each
|
||
owner runs the image under; empty for the host.
|
||
"""
|
||
|
||
key: str
|
||
kind: str
|
||
ref: str
|
||
label: str
|
||
owners: tuple[tuple[str | None, str | None], ...]
|
||
owner_tags: tuple[tuple[tuple[str | None, str | None], str], ...]
|
||
|
||
def tag_of(self, owner: tuple[str | None, str | None]) -> str | None:
|
||
return dict(self.owner_tags).get(owner)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Finding:
|
||
"""One vulnerable package as one engine reports it.
|
||
|
||
Args:
|
||
engine: the scanner.
|
||
target: :attr:`Target.key`.
|
||
package: package name.
|
||
installed: installed version.
|
||
fixed: fixed version(s), empty when there is none.
|
||
ids: vulnerability ids, CVE ids first.
|
||
severity: one of :data:`SEVERITIES`.
|
||
"""
|
||
|
||
engine: str
|
||
target: str
|
||
package: str
|
||
installed: str
|
||
fixed: str
|
||
ids: tuple[str, ...]
|
||
severity: str
|
||
|
||
@property
|
||
def id(self) -> str:
|
||
return self.ids[0]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Suppression:
|
||
"""One accepted CVE, or every finding of one image tag.
|
||
|
||
Args:
|
||
id: the CVE id, ``None`` for an image tag entry.
|
||
image_tag: the image tag, ``None`` for a CVE entry.
|
||
until: the last day the suppression applies.
|
||
"""
|
||
|
||
id: str | None
|
||
image_tag: str | None
|
||
until: dt.date
|
||
|
||
@property
|
||
def subject(self) -> str:
|
||
return self.id or f"image tag {self.image_tag}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Suppressions:
|
||
"""Accepted CVEs.
|
||
|
||
Args:
|
||
containers: ``{stack: {service: [Suppression]}}``.
|
||
host: ``{engine: [Suppression]}`` for host findings.
|
||
"""
|
||
|
||
containers: Mapping[str, Mapping[str, Sequence[Suppression]]]
|
||
host: Mapping[str, Sequence[Suppression]]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Step:
|
||
"""One engine run.
|
||
|
||
Args:
|
||
label: what the run is, for error lines; its first word is the engine.
|
||
argv: the command.
|
||
accept: exit codes that mean success.
|
||
"""
|
||
|
||
label: str
|
||
argv: tuple[str, ...]
|
||
accept: frozenset[int]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Run:
|
||
"""One run of the check, as every output names it.
|
||
|
||
Args:
|
||
id: unique id of the run.
|
||
started: when the run started, timezone-aware UTC.
|
||
host: the node's host name.
|
||
environment: the deployment environment, e.g. ``production``.
|
||
"""
|
||
|
||
id: str
|
||
started: dt.datetime
|
||
host: str
|
||
environment: str
|
||
|
||
def timestamp(self) -> str:
|
||
return self.started.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
def parse_args(argv=None):
|
||
parser = argparse.ArgumentParser(
|
||
description="Check the PHP hardening and the vulnerabilities of this node."
|
||
)
|
||
parser.add_argument(
|
||
"--hardened",
|
||
required=True,
|
||
help='JSON: {"<stack>": ["<service mounting php_hardening>", ...]}',
|
||
)
|
||
parser.add_argument(
|
||
"--scan",
|
||
required=True,
|
||
help="JSON: targets, engines, severity, fixed_only, workers, db_update",
|
||
)
|
||
parser.add_argument(
|
||
"--images",
|
||
required=True,
|
||
help='JSON: {"<engine>": "<image>:<version>"}',
|
||
)
|
||
parser.add_argument(
|
||
"--suppressions",
|
||
required=True,
|
||
help='JSON: {"containers": {stack: {service: [..]}}, "host": {engine: [..]}}',
|
||
)
|
||
parser.add_argument("--cache-dir", required=True, help="SBOM cache and scratch")
|
||
parser.add_argument(
|
||
"--database-dir", required=True, help="Vulnerability databases, one per scanner"
|
||
)
|
||
parser.add_argument(
|
||
"--host-excludes",
|
||
required=True,
|
||
help="JSON: absolute host paths the host scan skips",
|
||
)
|
||
parser.add_argument(
|
||
"--environment", required=True, help="Deployment environment of the node"
|
||
)
|
||
parser.add_argument(
|
||
"--report-dir", required=True, help=f"Directory of {REPORT}, the last run"
|
||
)
|
||
parser.add_argument(
|
||
"--log-dir", required=True, help=f"Directory of {FINDINGS_LOG}, every run"
|
||
)
|
||
parser.add_argument(
|
||
"--metrics-file",
|
||
help="Prometheus textfile to write; omitted or empty writes none",
|
||
)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def identity(labels: dict | None) -> tuple[str | None, str | None]:
|
||
"""Return the ``(stack, service)`` a container belongs to.
|
||
|
||
Args:
|
||
labels: the container's ``Config.Labels``, ``None`` when it has none.
|
||
|
||
Returns:
|
||
The swarm stack and service or the compose project and service;
|
||
``(None, None)`` for a container started outside both.
|
||
"""
|
||
if not labels:
|
||
return None, None
|
||
stack = labels.get("com.docker.stack.namespace")
|
||
if stack:
|
||
service = labels["com.docker.swarm.service.name"]
|
||
return stack, service.removeprefix(f"{stack}_")
|
||
project = labels.get("com.docker.compose.project")
|
||
if project:
|
||
return project, labels["com.docker.compose.service"]
|
||
return None, None
|
||
|
||
|
||
def running_containers() -> list[Container]:
|
||
ids = subprocess.run(
|
||
["container", "ps", "-q", "--no-trunc"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=EXEC_TIMEOUT_SECONDS,
|
||
).stdout.split()
|
||
if not ids:
|
||
return []
|
||
inspected = json.loads(
|
||
subprocess.run(
|
||
["container", "inspect", "--type", "container", *ids],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=EXEC_TIMEOUT_SECONDS,
|
||
).stdout
|
||
)
|
||
containers = []
|
||
for entry in inspected:
|
||
stack, service = identity(entry["Config"]["Labels"])
|
||
containers.append(
|
||
Container(
|
||
id=entry["Id"],
|
||
name=entry["Name"].lstrip("/"),
|
||
stack=stack,
|
||
service=service,
|
||
image=entry["Image"],
|
||
image_name=entry["Config"]["Image"],
|
||
)
|
||
)
|
||
return containers
|
||
|
||
|
||
def parse_probe(stdout: str) -> PhpProbe:
|
||
"""Read the probe line out of the ``php -r`` output.
|
||
|
||
Args:
|
||
stdout: everything php printed, startup warnings included.
|
||
|
||
Returns:
|
||
Whether each of HARDENED_SETTINGS is on.
|
||
"""
|
||
for line in stdout.splitlines():
|
||
if not line.startswith(f"{PROBE_MARKER}|"):
|
||
continue
|
||
values = line.split("|")[1:]
|
||
if len(values) != len(HARDENED_SETTINGS):
|
||
raise ProbeError(f"malformed probe line: {line!r}")
|
||
return PhpProbe(
|
||
settings={
|
||
name: value.strip().lower() in ENABLED_VALUES
|
||
for name, value in zip(HARDENED_SETTINGS, values, strict=True)
|
||
},
|
||
)
|
||
raise ProbeError(f"php printed no probe line: {stdout.strip()!r}")
|
||
|
||
|
||
def probe_php(container_id: str) -> PhpProbe | None:
|
||
"""Run the PHP probe inside a container.
|
||
|
||
Args:
|
||
container_id: the container to exec into.
|
||
|
||
Returns:
|
||
The probe result, or ``None`` when the container has no ``php``.
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["container", "exec", container_id, "php", "-r", PHP_PROBE],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=EXEC_TIMEOUT_SECONDS,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise ProbeError(f"php did not answer within {exc.timeout}s") from exc
|
||
if result.returncode in NO_BINARY_EXIT_CODES:
|
||
return None
|
||
if result.returncode != 0:
|
||
output = result.stderr.strip() or result.stdout.strip()
|
||
raise ProbeError(f"php exited {result.returncode}: {output}")
|
||
return parse_probe(result.stdout)
|
||
|
||
|
||
def check(
|
||
containers: list[Container],
|
||
hardened: dict,
|
||
probe,
|
||
) -> tuple[list[str], list[str]]:
|
||
"""Evaluate the PHP hardening of every container.
|
||
|
||
Args:
|
||
containers: the running containers.
|
||
hardened: ``{stack: [service, ...]}`` that must run hardened PHP.
|
||
probe: callable returning a :class:`PhpProbe` or ``None`` per id.
|
||
|
||
Returns:
|
||
``(failures, infos)`` as printable lines.
|
||
"""
|
||
failures: list[str] = []
|
||
infos: list[str] = []
|
||
for container in sorted(containers, key=lambda c: c.name):
|
||
in_hardened_stack = container.stack in hardened
|
||
must_harden = (
|
||
in_hardened_stack and container.service in hardened[container.stack]
|
||
)
|
||
if not in_hardened_stack:
|
||
continue
|
||
try:
|
||
result = probe(container.id)
|
||
except ProbeError as exc:
|
||
failures.append(f"{container.name}: {exc}")
|
||
continue
|
||
if result is None:
|
||
if must_harden:
|
||
failures.append(
|
||
f"{container.name}: declares php_hardening but has no php to verify it"
|
||
)
|
||
continue
|
||
enabled = [name for name, on in result.settings.items() if on]
|
||
if not enabled:
|
||
continue
|
||
message = f"{container.name}: {', '.join(enabled)} on"
|
||
if must_harden:
|
||
failures.append(f"{message}, the php_hardening ini is not in effect")
|
||
else:
|
||
infos.append(f"{message} (sidecar without php_hardening)")
|
||
return failures, infos
|
||
|
||
|
||
def severity_actions(raw) -> dict[str, str]:
|
||
"""Validate the mapping of severity to action.
|
||
|
||
Args:
|
||
raw: ``{severity: action}``, e.g. ``{CRITICAL: fail, HIGH: warn}``.
|
||
|
||
Returns:
|
||
The mapping in :data:`SEVERITIES` order.
|
||
|
||
Raises:
|
||
ConfigError: *raw* is the retired list form, empty, names an unknown
|
||
severity or maps one to anything but an entry of :data:`ACTIONS`.
|
||
"""
|
||
if isinstance(raw, list):
|
||
raise ConfigError(
|
||
"severity must map each severity to fail or warn, e.g. "
|
||
"{CRITICAL: fail, HIGH: warn}; the list form is no longer accepted"
|
||
)
|
||
if not isinstance(raw, Mapping) or not raw:
|
||
raise ConfigError(
|
||
"severity must be a non-empty mapping of severity to fail or warn"
|
||
)
|
||
unknown = sorted(str(key) for key in raw if key not in SEVERITIES)
|
||
if unknown:
|
||
raise ConfigError(
|
||
f"unknown severity: {', '.join(unknown)}; expected {', '.join(SEVERITIES)}"
|
||
)
|
||
invalid = sorted(
|
||
f"{key}: {value}" for key, value in raw.items() if value not in ACTIONS
|
||
)
|
||
if invalid:
|
||
raise ConfigError(
|
||
f"severity actions must be {' or '.join(ACTIONS)}; got {', '.join(invalid)}"
|
||
)
|
||
return {severity: raw[severity] for severity in SEVERITIES if severity in raw}
|
||
|
||
|
||
def scan_config(raw: Mapping) -> ScanConfig:
|
||
"""Validate the scan configuration.
|
||
|
||
Args:
|
||
raw: ``targets``, ``engines``, ``severity``, ``fixed_only``,
|
||
``workers`` and ``db_update``.
|
||
|
||
Raises:
|
||
ConfigError: a value is missing, unknown or contradictory.
|
||
"""
|
||
required = {"targets", "engines", "severity", "fixed_only", "workers", "db_update"}
|
||
missing = sorted(required - set(raw))
|
||
if missing:
|
||
raise ConfigError(f"scan config lacks {', '.join(missing)}")
|
||
for key, known in (
|
||
("targets", TARGET_KINDS),
|
||
("engines", ENGINES),
|
||
):
|
||
values = raw[key]
|
||
if not isinstance(values, list) or not values:
|
||
raise ConfigError(f"{key} must be a non-empty list")
|
||
unknown = sorted(set(values) - set(known))
|
||
if unknown:
|
||
raise ConfigError(
|
||
f"unknown {key}: {', '.join(unknown)}; expected {', '.join(known)}"
|
||
)
|
||
for key in ("fixed_only", "db_update"):
|
||
if not isinstance(raw[key], bool):
|
||
raise ConfigError(f"{key} must be true or false")
|
||
workers = raw["workers"]
|
||
if isinstance(workers, bool) or not isinstance(workers, int) or workers < 1:
|
||
raise ConfigError("workers must be a positive integer")
|
||
config = ScanConfig(
|
||
targets=tuple(kind for kind in TARGET_KINDS if kind in raw["targets"]),
|
||
engines=tuple(engine for engine in ENGINES if engine in raw["engines"]),
|
||
severity=severity_actions(raw["severity"]),
|
||
fixed_only=raw["fixed_only"],
|
||
workers=workers,
|
||
db_update=raw["db_update"],
|
||
)
|
||
if not config.scanners():
|
||
raise ConfigError(
|
||
f"engines name no scanner; expected one of {', '.join(SCANNERS)}"
|
||
)
|
||
if RUNTIME_ENGINE not in config.engines and SBOM_BUILDER not in config.engines:
|
||
raise ConfigError(
|
||
f"source-built runtimes need {RUNTIME_ENGINE} or {SBOM_BUILDER} in engines"
|
||
)
|
||
if (
|
||
"osv" in config.engines
|
||
and "host" in config.targets
|
||
and not config.reads_sbom("osv")
|
||
):
|
||
raise ConfigError("osv reads no OS packages of the host without syft")
|
||
return config
|
||
|
||
|
||
def suppressions_from_json(raw: Mapping) -> Suppressions:
|
||
def entries(items: Iterable[Mapping]) -> tuple[Suppression, ...]:
|
||
return tuple(
|
||
Suppression(
|
||
id=item["id"],
|
||
image_tag=item["image_tag"],
|
||
until=dt.date.fromisoformat(item["until"]),
|
||
)
|
||
for item in items
|
||
)
|
||
|
||
return Suppressions(
|
||
containers={
|
||
stack: {service: entries(items) for service, items in services.items()}
|
||
for stack, services in raw["containers"].items()
|
||
},
|
||
host={engine: entries(items) for engine, items in raw["host"].items()},
|
||
)
|
||
|
||
|
||
def vulnerability_ids(ids: Iterable[str]) -> tuple[str, ...]:
|
||
"""Return the distinct ids, CVE ids first, each part sorted."""
|
||
unique = {item for item in ids if item}
|
||
cves = sorted(item for item in unique if CVE_PATTERN.match(item))
|
||
return (*cves, *sorted(unique - set(cves)))
|
||
|
||
|
||
def cvss_severity(score: str) -> str:
|
||
"""Map a CVSS base score to its qualitative rating."""
|
||
if not score:
|
||
return "UNKNOWN"
|
||
value = float(score)
|
||
if value >= 9.0:
|
||
return "CRITICAL"
|
||
if value >= 7.0:
|
||
return "HIGH"
|
||
if value >= 4.0:
|
||
return "MEDIUM"
|
||
if value > 0.0:
|
||
return "LOW"
|
||
return "NONE"
|
||
|
||
|
||
def parse_trivy(report: Mapping, target: str) -> list[Finding]:
|
||
"""Normalise a ``trivy --format json`` report."""
|
||
return [
|
||
Finding(
|
||
engine="trivy",
|
||
target=target,
|
||
package=vuln["PkgName"],
|
||
installed=vuln["InstalledVersion"],
|
||
fixed=vuln.get("FixedVersion") or "",
|
||
ids=vulnerability_ids(
|
||
[vuln["VulnerabilityID"], *(vuln.get("VendorIDs") or [])]
|
||
),
|
||
severity=vuln["Severity"].upper(),
|
||
)
|
||
for result in report.get("Results") or []
|
||
for vuln in result.get("Vulnerabilities") or []
|
||
]
|
||
|
||
|
||
def parse_grype(report: Mapping, target: str) -> list[Finding]:
|
||
"""Normalise a ``grype -o json`` report."""
|
||
findings = []
|
||
for match in report["matches"]:
|
||
vuln = match["vulnerability"]
|
||
artifact = match["artifact"]
|
||
fix = vuln["fix"]
|
||
related = [item["id"] for item in match.get("relatedVulnerabilities") or []]
|
||
findings.append(
|
||
Finding(
|
||
engine="grype",
|
||
target=target,
|
||
package=artifact["name"],
|
||
installed=artifact["version"],
|
||
fixed=", ".join(fix["versions"]) if fix["state"] == "fixed" else "",
|
||
ids=vulnerability_ids([vuln["id"], *related]),
|
||
severity=vuln["severity"].upper(),
|
||
)
|
||
)
|
||
return findings
|
||
|
||
|
||
def _osv_fixed(vulns: Iterable[Mapping], package: Mapping) -> str:
|
||
fixed = {
|
||
event["fixed"]
|
||
for vuln in vulns
|
||
for affected in vuln.get("affected") or []
|
||
if affected["package"]["name"] == package["name"]
|
||
and affected["package"]["ecosystem"] == package["ecosystem"]
|
||
for version_range in affected.get("ranges") or []
|
||
for event in version_range.get("events") or []
|
||
if "fixed" in event
|
||
}
|
||
return ", ".join(sorted(fixed))
|
||
|
||
|
||
def parse_osv(report: Mapping, target_of: Callable[[str], str]) -> list[Finding]:
|
||
"""Normalise an ``osv-scanner --format json`` report.
|
||
|
||
Args:
|
||
report: the report.
|
||
target_of: maps a result's ``source.path`` to its target key.
|
||
|
||
Returns:
|
||
One finding per package and OSV group. The severity is the group's
|
||
highest CVSS score mapped by :func:`cvss_severity`; the fixed
|
||
versions are the ``fixed`` events of the group's ranges for that
|
||
package.
|
||
"""
|
||
findings = []
|
||
for result in report.get("results") or []:
|
||
target = target_of(result["source"]["path"])
|
||
for entry in result["packages"]:
|
||
package = entry["package"]
|
||
by_id = {vuln["id"]: vuln for vuln in entry.get("vulnerabilities") or []}
|
||
for group in entry.get("groups") or []:
|
||
members = [by_id[item] for item in group["ids"] if item in by_id]
|
||
ids = [*group["ids"], *(group.get("aliases") or [])]
|
||
for vuln in members:
|
||
ids += [*(vuln.get("aliases") or []), *(vuln.get("upstream") or [])]
|
||
findings.append(
|
||
Finding(
|
||
engine="osv",
|
||
target=target,
|
||
package=package["name"],
|
||
installed=package["version"],
|
||
fixed=_osv_fixed(members, package),
|
||
ids=vulnerability_ids(ids),
|
||
severity=cvss_severity(group.get("max_severity") or ""),
|
||
)
|
||
)
|
||
return findings
|
||
|
||
|
||
def osv_sbom(cyclonedx: Mapping) -> dict:
|
||
"""Return a CycloneDX copy whose distro purls name the source package.
|
||
|
||
OSV keys Debian, Ubuntu and Alpine advisories by source package, while
|
||
Syft names the binary package and carries the source in the ``upstream``
|
||
qualifier (``libssl3`` from ``openssl``).
|
||
|
||
Args:
|
||
cyclonedx: a Syft ``cyclonedx-json`` document.
|
||
"""
|
||
document = json.loads(json.dumps(cyclonedx))
|
||
for component in document.get("components") or []:
|
||
purl = component.get("purl") or ""
|
||
kind = purl.removeprefix("pkg:").partition("/")[0]
|
||
match = UPSTREAM_QUALIFIER.search(purl)
|
||
if kind not in OSV_SOURCE_PURL_TYPES or match is None:
|
||
continue
|
||
head, sep, qualifiers = purl.partition("?")
|
||
path, _, version = head.partition("@")
|
||
source, _, source_version = urllib.parse.unquote(match.group(2)).partition("@")
|
||
namespace = path.rpartition("/")[0]
|
||
component["purl"] = (
|
||
f"{namespace}/{source}@{source_version or version}{sep}{qualifiers}"
|
||
)
|
||
return document
|
||
|
||
|
||
def runtime_sbom(syft: Mapping) -> dict | None:
|
||
"""Return a Syft JSON copy that holds only the binary-classified packages.
|
||
|
||
Args:
|
||
syft: a Syft ``syft-json`` document.
|
||
|
||
Returns:
|
||
The reduced document, or ``None`` when Syft classified no binary.
|
||
"""
|
||
binaries = [
|
||
artifact
|
||
for artifact in syft.get("artifacts") or []
|
||
if artifact.get("type") == RUNTIME_ARTIFACT_TYPE
|
||
]
|
||
if not binaries:
|
||
return None
|
||
reduced = {key: value for key, value in syft.items() if key != "files"}
|
||
reduced["artifacts"] = binaries
|
||
reduced["artifactRelationships"] = []
|
||
return reduced
|
||
|
||
|
||
def threshold(finding: Finding, config: ScanConfig) -> str | None:
|
||
"""Map a finding to the action its severity carries.
|
||
|
||
Returns:
|
||
An entry of :data:`ACTIONS`, ``None`` below the threshold.
|
||
"""
|
||
if config.fixed_only and not finding.fixed:
|
||
return None
|
||
return config.severity.get(finding.severity)
|
||
|
||
|
||
def image_tag(name: str) -> str:
|
||
"""Return the tag of an image reference, ``latest`` when it names none.
|
||
|
||
Args:
|
||
name: ``[registry/]repository[:tag][@digest]``.
|
||
"""
|
||
repository = name.split("@", 1)[0].rsplit("/", 1)[-1]
|
||
_, separator, tag = repository.partition(":")
|
||
return tag if separator else "latest"
|
||
|
||
|
||
def matching(
|
||
rules: Sequence[Suppression], finding: Finding, tag: str | None
|
||
) -> Suppression | None:
|
||
"""Return the suppression covering the finding, latest first.
|
||
|
||
Args:
|
||
rules: the suppressions of the finding's owner.
|
||
finding: the finding.
|
||
tag: the tag of the scanned image, ``None`` for the host, where no
|
||
image tag entry applies.
|
||
"""
|
||
hits = [
|
||
rule
|
||
for rule in rules
|
||
if (
|
||
rule.id in finding.ids
|
||
if rule.id
|
||
else tag is not None and rule.image_tag == tag
|
||
)
|
||
]
|
||
return max(hits, key=lambda rule: rule.until) if hits else None
|
||
|
||
|
||
def _owner_label(owner: tuple[str | None, str | None]) -> str:
|
||
stack, service = owner
|
||
if stack is None:
|
||
return "unmanaged container"
|
||
return f"{stack}/{service}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Verdict:
|
||
"""One finding at the threshold, judged for one owner.
|
||
|
||
Args:
|
||
finding: the finding.
|
||
target: the target it was found in.
|
||
owner: the ``(stack, service)`` it is judged for, ``(None, None)``
|
||
for the host and for unmanaged containers.
|
||
rule: the suppression that names it, ``None`` when none does.
|
||
suppressed: whether that suppression is still in force.
|
||
action: what the finding's severity maps to, an entry of
|
||
:data:`ACTIONS`.
|
||
"""
|
||
|
||
finding: Finding
|
||
target: Target
|
||
owner: tuple[str | None, str | None]
|
||
rule: Suppression | None
|
||
suppressed: bool
|
||
action: str
|
||
|
||
@property
|
||
def verdict(self) -> str:
|
||
return SUPPRESSED if self.suppressed else self.action
|
||
|
||
def where(self) -> str:
|
||
scope = self.target.label
|
||
if self.target.kind == "container":
|
||
scope = f"{_owner_label(self.owner)} {scope}"
|
||
return f"{self.finding.engine} {self.target.kind} {scope}"
|
||
|
||
def detail(self) -> str:
|
||
finding = self.finding
|
||
fixed = f"fixed in {finding.fixed}" if finding.fixed else "no fix"
|
||
return f"{finding.id} {finding.severity} {finding.package} {finding.installed} ({fixed})"
|
||
|
||
|
||
def verdicts(
|
||
findings: Iterable[Finding],
|
||
targets: Mapping[str, Target],
|
||
config: ScanConfig,
|
||
suppressions: Suppressions,
|
||
today: dt.date,
|
||
) -> list[Verdict]:
|
||
"""Judge the findings against the threshold and the suppressions.
|
||
|
||
A role's suppressions cover only the containers of its own stack and
|
||
service; a host suppression covers only the engine it is declared
|
||
under.
|
||
"""
|
||
judged = []
|
||
for finding in sorted(
|
||
set(findings),
|
||
key=lambda f: (f.target, f.engine, f.package, f.id, f.installed),
|
||
):
|
||
action = threshold(finding, config)
|
||
if action is None:
|
||
continue
|
||
target = targets[finding.target]
|
||
for owner in owners_of(target):
|
||
if target.kind == "host":
|
||
rules = suppressions.host.get(finding.engine, ())
|
||
elif owner[0] is None:
|
||
rules = ()
|
||
else:
|
||
rules = suppressions.containers.get(owner[0], {}).get(owner[1], ())
|
||
rule = matching(rules, finding, target.tag_of(owner))
|
||
judged.append(
|
||
Verdict(
|
||
finding,
|
||
target,
|
||
owner,
|
||
rule,
|
||
rule is not None and rule.until >= today,
|
||
action,
|
||
)
|
||
)
|
||
return judged
|
||
|
||
|
||
def owners_of(target: Target) -> list[tuple[str | None, str | None]]:
|
||
"""Return the distinct owners a target's findings are judged for."""
|
||
if target.kind == "host":
|
||
return [(None, None)]
|
||
return sorted(set(target.owners), key=str)
|
||
|
||
|
||
def report_lines(
|
||
judged: Iterable[Verdict],
|
||
) -> tuple[list[str], list[str], list[str]]:
|
||
"""Return verdicts as printable lines.
|
||
|
||
Returns:
|
||
``(failures, warnings, infos)``: unsuppressed verdicts whose action is
|
||
``fail``, those whose action is ``warn``, and suppressions.
|
||
"""
|
||
failures: list[str] = []
|
||
warnings: list[str] = []
|
||
infos: list[str] = []
|
||
for verdict in judged:
|
||
line = f"{verdict.where()}: {verdict.detail()}"
|
||
if verdict.suppressed:
|
||
infos.append(f"{line} suppressed until {verdict.rule.until}")
|
||
continue
|
||
if verdict.rule is not None:
|
||
infos.append(
|
||
f"{verdict.where()}: suppression of {verdict.rule.subject} expired on {verdict.rule.until}"
|
||
)
|
||
(failures if verdict.action == "fail" else warnings).append(line)
|
||
return failures, warnings, infos
|
||
|
||
|
||
def evaluate(
|
||
findings: Iterable[Finding],
|
||
targets: Mapping[str, Target],
|
||
config: ScanConfig,
|
||
suppressions: Suppressions,
|
||
today: dt.date,
|
||
) -> tuple[list[str], list[str], list[str]]:
|
||
"""Return :func:`verdicts` as :func:`report_lines`."""
|
||
return report_lines(verdicts(findings, targets, config, suppressions, today))
|
||
|
||
|
||
def scope(target: Target, owner: tuple[str | None, str | None]) -> tuple[str, str]:
|
||
"""Name one owner of a target for the outputs.
|
||
|
||
Returns:
|
||
``(target, image)``: the owner and ``<image name>@<image id>`` for a
|
||
container, the host name twice for the host.
|
||
"""
|
||
if target.kind == "host":
|
||
return target.label, target.label
|
||
return _owner_label(owner), f"{target.label}@{target.ref}"
|
||
|
||
|
||
def record(verdict: Verdict, run: Run) -> dict:
|
||
"""Return one judged finding as the record every output carries."""
|
||
finding = verdict.finding
|
||
target, image = scope(verdict.target, verdict.owner)
|
||
return {
|
||
"run_id": run.id,
|
||
"timestamp": run.timestamp(),
|
||
"host": run.host,
|
||
"environment": run.environment,
|
||
"target": target,
|
||
"kind": verdict.target.kind,
|
||
"image": image,
|
||
"scanner": finding.engine,
|
||
"id": finding.id,
|
||
"severity": finding.severity,
|
||
"package": finding.package,
|
||
"installed_version": finding.installed,
|
||
"fixed_version": finding.fixed or None,
|
||
"suppressed": verdict.suppressed,
|
||
"suppressed_until": (
|
||
verdict.rule.until.isoformat() if verdict.suppressed else None
|
||
),
|
||
"verdict": verdict.verdict,
|
||
}
|
||
|
||
|
||
def tally(verdicts_of: Iterable[str]) -> dict[str, int]:
|
||
"""Return the count of each entry of :data:`VERDICTS`."""
|
||
counts = dict.fromkeys(VERDICTS, 0)
|
||
for verdict in verdicts_of:
|
||
counts[verdict] += 1
|
||
return counts
|
||
|
||
|
||
def worst(counts: Mapping[str, int]) -> str:
|
||
"""Return ``fail`` or ``warn`` while one is counted, else :data:`CLEAN`."""
|
||
return next((action for action in ACTIONS if counts[action]), CLEAN)
|
||
|
||
|
||
def error_engine(line: str) -> str:
|
||
"""Return the engine an error line starts with."""
|
||
return line.split(" ", 1)[0].removesuffix(":")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Outcome:
|
||
"""Everything one run reports.
|
||
|
||
Args:
|
||
run: the run.
|
||
targets: the scanned targets.
|
||
records: :func:`record` of every verdict.
|
||
errors: engine runs that did not deliver.
|
||
engines: the configured engines.
|
||
failed: whether the run fails.
|
||
finished: when the scan finished, Unix seconds.
|
||
"""
|
||
|
||
run: Run
|
||
targets: tuple[Target, ...]
|
||
records: tuple[dict, ...]
|
||
errors: tuple[str, ...]
|
||
engines: tuple[str, ...]
|
||
failed: bool
|
||
finished: float
|
||
|
||
def target_events(self) -> list[dict]:
|
||
events = []
|
||
for target in self.targets:
|
||
for owner in owners_of(target):
|
||
name, image = scope(target, owner)
|
||
counts = tally(
|
||
item["verdict"]
|
||
for item in self.records
|
||
if (item["target"], item["kind"], item["image"])
|
||
== (name, target.kind, image)
|
||
)
|
||
events.append(
|
||
{
|
||
"event": "hlth_runtime.target",
|
||
"run_id": self.run.id,
|
||
"target": name,
|
||
"kind": target.kind,
|
||
"image": image,
|
||
"counts": counts,
|
||
"verdict": worst(counts),
|
||
}
|
||
)
|
||
return events
|
||
|
||
def summary_event(self) -> dict:
|
||
counts = tally(item["verdict"] for item in self.records)
|
||
return {
|
||
"event": "hlth_runtime.summary",
|
||
"run_id": self.run.id,
|
||
"counts": counts,
|
||
"errors": len(self.errors),
|
||
"verdict": "fail" if self.failed else worst(counts),
|
||
}
|
||
|
||
def report(self) -> dict:
|
||
summary = self.summary_event()
|
||
return {
|
||
"run_id": self.run.id,
|
||
"timestamp": self.run.timestamp(),
|
||
"host": self.run.host,
|
||
"environment": self.run.environment,
|
||
"verdict": summary["verdict"],
|
||
"counts": summary["counts"],
|
||
"errors": list(self.errors),
|
||
"targets": [
|
||
{
|
||
key: value
|
||
for key, value in event.items()
|
||
if key not in ("event", "run_id")
|
||
}
|
||
for event in self.target_events()
|
||
],
|
||
"findings": list(self.records),
|
||
}
|
||
|
||
def metrics(self) -> str:
|
||
lines = [
|
||
f"# HELP {METRIC}_findings Judged vulnerability findings of the last run.",
|
||
f"# TYPE {METRIC}_findings gauge",
|
||
]
|
||
series = Counter(
|
||
tuple(item[label] for label in METRIC_LABELS) for item in self.records
|
||
)
|
||
for values, count in sorted(series.items()):
|
||
labels = ",".join(
|
||
f'{label}="{_label_value(value)}"'
|
||
for label, value in zip(METRIC_LABELS, values, strict=True)
|
||
)
|
||
lines.append(f"{METRIC}_findings{{{labels}}} {count}")
|
||
lines += [
|
||
f"# HELP {METRIC}_last_run_timestamp_seconds When the last run finished.",
|
||
f"# TYPE {METRIC}_last_run_timestamp_seconds gauge",
|
||
f"{METRIC}_last_run_timestamp_seconds {int(self.finished)}",
|
||
f"# HELP {METRIC}_last_run_success 1 when the last run did not fail, else 0.",
|
||
f"# TYPE {METRIC}_last_run_success gauge",
|
||
f"{METRIC}_last_run_success {0 if self.failed else 1}",
|
||
f"# HELP {METRIC}_scanner_errors Engine runs of the last run that did not deliver.",
|
||
f"# TYPE {METRIC}_scanner_errors gauge",
|
||
]
|
||
errors = Counter(error_engine(line) for line in self.errors)
|
||
for engine in sorted({*self.engines, *errors}):
|
||
lines.append(
|
||
f'{METRIC}_scanner_errors{{scanner="{_label_value(engine)}"}} {errors[engine]}'
|
||
)
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _label_value(value: str) -> str:
|
||
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
|
||
|
||
|
||
def write_atomic(path: Path, text: str) -> None:
|
||
staged = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||
try:
|
||
staged.write_text(text, encoding="utf-8")
|
||
staged.replace(path)
|
||
finally:
|
||
staged.unlink(missing_ok=True)
|
||
|
||
|
||
def publish(
|
||
outcome: Outcome, report_dir: Path, log_dir: Path, metrics_file: str | None
|
||
) -> list[dict]:
|
||
"""Write the outputs of one run.
|
||
|
||
Args:
|
||
outcome: the run's results.
|
||
report_dir: where :data:`REPORT` is replaced.
|
||
log_dir: where :data:`FINDINGS_LOG` grows by one line per record.
|
||
metrics_file: the Prometheus textfile; ``None`` or empty writes none.
|
||
|
||
Returns:
|
||
The journal events, one per target and the summary last.
|
||
"""
|
||
write_atomic(report_dir / REPORT, json.dumps(outcome.report(), indent=2) + "\n")
|
||
if outcome.records:
|
||
with (log_dir / FINDINGS_LOG).open("a", encoding="utf-8") as handle:
|
||
handle.write("".join(json.dumps(item) + "\n" for item in outcome.records))
|
||
if metrics_file:
|
||
write_atomic(Path(metrics_file), outcome.metrics())
|
||
return [*outcome.target_events(), outcome.summary_event()]
|
||
|
||
|
||
def container_targets(containers: Iterable[Container]) -> list[Target]:
|
||
"""Group the running containers by image."""
|
||
grouped: dict[str, list[Container]] = {}
|
||
for container in containers:
|
||
grouped.setdefault(container.image, []).append(container)
|
||
targets = []
|
||
for image, members in sorted(grouped.items()):
|
||
members.sort(key=lambda c: c.name)
|
||
targets.append(
|
||
Target(
|
||
key=image.removeprefix("sha256:"),
|
||
kind="container",
|
||
ref=image,
|
||
label=members[0].image_name,
|
||
owners=tuple((member.stack, member.service) for member in members),
|
||
owner_tags=tuple(
|
||
((member.stack, member.service), image_tag(member.image_name))
|
||
for member in members
|
||
),
|
||
)
|
||
)
|
||
return targets
|
||
|
||
|
||
def host_target(hostname: str) -> Target:
|
||
return Target(
|
||
key="host",
|
||
kind="host",
|
||
ref=HOST_ROOT,
|
||
label=hostname,
|
||
owners=(),
|
||
owner_tags=(),
|
||
)
|
||
|
||
|
||
def _env(pairs: Mapping[str, str]) -> list[str]:
|
||
args = []
|
||
for key, value in sorted(pairs.items()):
|
||
args += ["-e", f"{key}={value}"]
|
||
return args
|
||
|
||
|
||
ENGINE_ENV = {
|
||
"trivy": {},
|
||
"grype": {"GRYPE_DB_CACHE_DIR": CACHE, "GRYPE_CHECK_FOR_APP_UPDATE": "false"},
|
||
"osv": {"OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY": CACHE},
|
||
"syft": {"SYFT_CHECK_FOR_APP_UPDATE": "false"},
|
||
}
|
||
TRIVY_COMMON = (
|
||
"--cache-dir",
|
||
CACHE,
|
||
"--disable-telemetry",
|
||
"--skip-version-check",
|
||
"--quiet",
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Layout:
|
||
"""Where one scan run keeps its files.
|
||
|
||
Args:
|
||
runtime: the command that starts a container, e.g. ``container run``.
|
||
databases: the database root, one directory per scanner.
|
||
sbom: the SBOM directory.
|
||
work: the per-run scratch directory.
|
||
excludes: absolute host paths the host scan skips.
|
||
"""
|
||
|
||
runtime: tuple[str, ...]
|
||
databases: Path
|
||
sbom: Path
|
||
work: Path
|
||
excludes: tuple[str, ...]
|
||
|
||
|
||
def run_argv(
|
||
layout: Layout,
|
||
engine: str,
|
||
image: str,
|
||
args: Sequence[str],
|
||
*,
|
||
target: Target | None,
|
||
extra_env: Mapping[str, str],
|
||
) -> tuple[str, ...]:
|
||
"""Build the command that runs one engine container.
|
||
|
||
Args:
|
||
layout: where the run keeps its files.
|
||
engine: the engine, which picks its database mount and environment.
|
||
image: the engine image.
|
||
args: the engine's arguments.
|
||
target: the target, which picks the host or Docker socket mount;
|
||
``None`` for a run that reads neither.
|
||
extra_env: environment on top of the engine's own.
|
||
"""
|
||
mounts = ["-v", f"{layout.sbom}:{SBOM}", "-v", f"{layout.work}:{WORK}"]
|
||
if engine in SCANNERS:
|
||
mounts += ["-v", f"{layout.databases / engine}:{CACHE}"]
|
||
if target is not None and target.kind == "host":
|
||
mounts += ["-v", f"/:{HOST_ROOT}:ro"]
|
||
if target is not None and target.kind == "container":
|
||
mounts += ["-v", f"{DOCKER_SOCKET}:{DOCKER_SOCKET}:ro"]
|
||
environment = {"TMPDIR": WORK, **ENGINE_ENV[engine], **extra_env}
|
||
return (*layout.runtime, "--rm", *mounts, *_env(environment), image, *args)
|
||
|
||
|
||
def _relative_excludes(layout: Layout) -> list[str]:
|
||
return [f"./{path.strip('/')}" for path in layout.excludes]
|
||
|
||
|
||
def db_steps(engine: str, layout: Layout, image: str) -> list[Step]:
|
||
"""Download or refresh one engine's database."""
|
||
if engine == "trivy":
|
||
return [
|
||
Step(
|
||
f"trivy {name}",
|
||
run_argv(
|
||
layout,
|
||
engine,
|
||
image,
|
||
["image", flag, *TRIVY_COMMON],
|
||
target=None,
|
||
extra_env={},
|
||
),
|
||
SUCCESS,
|
||
)
|
||
for name, flag in (
|
||
("database", "--download-db-only"),
|
||
("java database", "--download-java-db-only"),
|
||
)
|
||
]
|
||
if engine == "grype":
|
||
return [
|
||
Step(
|
||
"grype database",
|
||
run_argv(
|
||
layout, engine, image, ["db", "update"], target=None, extra_env={}
|
||
),
|
||
SUCCESS,
|
||
)
|
||
]
|
||
return []
|
||
|
||
|
||
def sbom_paths(layout: Layout, target: Target) -> tuple[Path, Path]:
|
||
return (
|
||
layout.sbom / f"{target.key}.syft.json",
|
||
layout.sbom / f"{target.key}.cdx.json",
|
||
)
|
||
|
||
|
||
def sbom_step(layout: Layout, image: str, target: Target) -> Step:
|
||
"""Build one target's SBOM in Syft and CycloneDX JSON."""
|
||
if target.kind == "host":
|
||
source = [f"dir:{HOST_ROOT}", "--override-default-catalogers", "image"]
|
||
for path in _relative_excludes(layout):
|
||
source += ["--exclude", path]
|
||
else:
|
||
source = [f"docker:{target.ref}"]
|
||
args = [
|
||
"scan",
|
||
*source,
|
||
"-o",
|
||
f"syft-json={SBOM}/{target.key}.syft.json",
|
||
"-o",
|
||
f"cyclonedx-json={SBOM}/{target.key}.cdx.json",
|
||
"-q",
|
||
]
|
||
return Step(
|
||
f"syft {target.kind} {target.label}",
|
||
run_argv(layout, "syft", image, args, target=target, extra_env={}),
|
||
SUCCESS,
|
||
)
|
||
|
||
|
||
def trivy_step(layout: Layout, image: str, target: Target, config: ScanConfig) -> Step:
|
||
args = [
|
||
"rootfs" if target.kind == "host" else "image",
|
||
*TRIVY_COMMON,
|
||
"--skip-db-update",
|
||
"--skip-java-db-update",
|
||
"--scanners",
|
||
"vuln",
|
||
"--format",
|
||
"json",
|
||
"--output",
|
||
f"{WORK}/{target.key}.trivy.json",
|
||
]
|
||
if not config.db_update:
|
||
args += ["--cache-backend", "memory"]
|
||
if target.kind == "host":
|
||
args += [
|
||
"--skip-dirs",
|
||
",".join(f"{HOST_ROOT}{path}" for path in layout.excludes),
|
||
HOST_ROOT,
|
||
]
|
||
else:
|
||
args += ["--image-src", "docker", target.ref]
|
||
return Step(
|
||
f"trivy {target.kind} {target.label}",
|
||
run_argv(layout, "trivy", image, args, target=target, extra_env={}),
|
||
SUCCESS,
|
||
)
|
||
|
||
|
||
def grype_step(layout: Layout, image: str, target: Target, config: ScanConfig) -> Step:
|
||
if config.reads_sbom("grype"):
|
||
source = [f"sbom:{SBOM}/{target.key}.syft.json"]
|
||
elif target.kind == "host":
|
||
source = [f"dir:{HOST_ROOT}"]
|
||
for path in _relative_excludes(layout):
|
||
source += ["--exclude", path]
|
||
else:
|
||
source = [f"docker:{target.ref}"]
|
||
args = [*source, "-o", "json", "--file", f"{WORK}/{target.key}.grype.json", "-q"]
|
||
return Step(
|
||
f"grype {target.kind} {target.label}",
|
||
run_argv(
|
||
layout,
|
||
"grype",
|
||
image,
|
||
args,
|
||
target=target,
|
||
extra_env={"GRYPE_DB_AUTO_UPDATE": "false"},
|
||
),
|
||
SUCCESS,
|
||
)
|
||
|
||
|
||
def osv_args(config: ScanConfig, output: str) -> list[str]:
|
||
args = ["--offline-vulnerabilities", "--format", "json", "--output-file", output]
|
||
if config.db_update:
|
||
args.append("--download-offline-databases")
|
||
return args
|
||
|
||
|
||
def osv_sbom_step(
|
||
layout: Layout, image: str, targets: Sequence[Target], config: ScanConfig
|
||
) -> Step:
|
||
"""Scan every target's rewritten CycloneDX in one OSV run."""
|
||
args = ["scan", "source", *osv_args(config, f"{WORK}/osv.json")]
|
||
for target in targets:
|
||
args += ["-L", f"{WORK}/{target.key}.osv.cdx.json"]
|
||
return Step(
|
||
"osv sbom",
|
||
run_argv(layout, "osv", image, args, target=None, extra_env={}),
|
||
OSV_ACCEPTED_EXIT_CODES,
|
||
)
|
||
|
||
|
||
def osv_image_steps(
|
||
layout: Layout, image: str, target: Target, config: ScanConfig
|
||
) -> list[Step]:
|
||
"""Export one image and scan the archive; OSV reads no docker socket."""
|
||
archive = layout.work / "osv-image.tar"
|
||
return [
|
||
Step(
|
||
f"osv export {target.label}",
|
||
(layout.runtime[0], "save", "--output", str(archive), target.ref),
|
||
SUCCESS,
|
||
),
|
||
Step(
|
||
f"osv container {target.label}",
|
||
run_argv(
|
||
layout,
|
||
"osv",
|
||
image,
|
||
[
|
||
"scan",
|
||
"image",
|
||
"--archive",
|
||
f"{WORK}/osv-image.tar",
|
||
*osv_args(config, f"{WORK}/{target.key}.osv.json"),
|
||
],
|
||
target=None,
|
||
extra_env={},
|
||
),
|
||
OSV_ACCEPTED_EXIT_CODES,
|
||
),
|
||
]
|
||
|
||
|
||
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess]
|
||
|
||
|
||
def default_runner(argv: Sequence[str]) -> subprocess.CompletedProcess:
|
||
return subprocess.run(
|
||
list(argv),
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
timeout=JOB_TIMEOUT_SECONDS,
|
||
)
|
||
|
||
|
||
def _tail(text: str) -> str:
|
||
lines = [line for line in text.strip().splitlines() if line.strip()]
|
||
return " | ".join(lines[-3:])
|
||
|
||
|
||
def run_lane(lane: Sequence[Step], runner: Runner) -> list[str]:
|
||
"""Run steps in order and stop at the first failing one.
|
||
|
||
Returns:
|
||
Error lines, empty when every step succeeded.
|
||
"""
|
||
for step in lane:
|
||
try:
|
||
result = runner(step.argv)
|
||
except subprocess.TimeoutExpired as exc:
|
||
return [f"{step.label}: no result within {exc.timeout}s"]
|
||
missing = OSV_MISSING_DB.findall(f"{result.stdout}\n{result.stderr}")
|
||
if missing:
|
||
return [
|
||
f"{step.label}: no OSV database for {', '.join(sorted(set(missing)))}"
|
||
]
|
||
if result.returncode not in step.accept:
|
||
output = _tail(result.stderr) or _tail(result.stdout)
|
||
return [f"{step.label}: exited {result.returncode}: {output}"]
|
||
return []
|
||
|
||
|
||
def run_lanes(
|
||
lanes: Sequence[Sequence[Step]], runner: Runner, workers: int
|
||
) -> list[list[str]]:
|
||
"""Run lanes in parallel, at most *workers* at a time.
|
||
|
||
Returns:
|
||
The error lines of each lane, in lane order.
|
||
"""
|
||
if not lanes:
|
||
return []
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
return list(pool.map(lambda lane: run_lane(lane, runner), lanes))
|
||
|
||
|
||
def _flat(errors: Iterable[Sequence[str]]) -> list[str]:
|
||
return [line for lines in errors for line in lines]
|
||
|
||
|
||
def _read_json(path: Path) -> dict:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def missing_databases(engine: str, databases: Path) -> list[str]:
|
||
"""Name every file of a provided database that is not there."""
|
||
return [
|
||
f"{engine}: provided database {databases / engine / marker} is missing"
|
||
for marker in DB_MARKERS[engine]
|
||
if not (databases / engine / marker).exists()
|
||
]
|
||
|
||
|
||
def prepare_databases(
|
||
config: ScanConfig, images: Mapping[str, str], layout: Layout, runner: Runner
|
||
) -> dict[str, list[str]]:
|
||
"""Refresh or check each database the scan needs.
|
||
|
||
Returns:
|
||
``{engine: error lines}``; an engine with errors does not scan.
|
||
"""
|
||
needed = config.databases()
|
||
if not config.db_update:
|
||
return {
|
||
engine: missing_databases(engine, layout.databases) for engine in needed
|
||
}
|
||
for engine in needed:
|
||
(layout.databases / engine).mkdir(parents=True, exist_ok=True)
|
||
results: dict[str, list[str]] = {}
|
||
pending = list(needed)
|
||
for attempt in range(DB_ATTEMPTS):
|
||
if attempt:
|
||
pause(DB_RETRY_SECONDS)
|
||
lanes = [db_steps(engine, layout, images[engine]) for engine in pending]
|
||
results.update(
|
||
zip(pending, run_lanes(lanes, runner, config.workers), strict=True)
|
||
)
|
||
pending = [engine for engine in pending if results[engine]]
|
||
if not pending:
|
||
break
|
||
return {engine: results[engine] for engine in needed}
|
||
|
||
|
||
def pause(seconds: float) -> None:
|
||
time.sleep(seconds)
|
||
|
||
|
||
def runtime_step(layout: Layout, image: str, target: Target) -> Step:
|
||
"""Scan the binary-classified packages of one SBOM with Grype."""
|
||
args = [
|
||
f"sbom:{WORK}/{target.key}.runtime.syft.json",
|
||
"-o",
|
||
"json",
|
||
"--file",
|
||
f"{WORK}/{target.key}.runtime.grype.json",
|
||
"-q",
|
||
]
|
||
return Step(
|
||
f"grype runtime {target.kind} {target.label}",
|
||
run_argv(
|
||
layout,
|
||
RUNTIME_ENGINE,
|
||
image,
|
||
args,
|
||
target=None,
|
||
extra_env={"GRYPE_DB_AUTO_UPDATE": "false"},
|
||
),
|
||
SUCCESS,
|
||
)
|
||
|
||
|
||
def scan(
|
||
targets: Sequence[Target],
|
||
config: ScanConfig,
|
||
images: Mapping[str, str],
|
||
layout: Layout,
|
||
runner: Runner,
|
||
) -> tuple[list[Finding], list[str]]:
|
||
"""Run every engine over every target.
|
||
|
||
Returns:
|
||
``(findings, errors)``; an error is an engine run that did not
|
||
deliver, which fails the scan.
|
||
"""
|
||
databases = prepare_databases(config, images, layout, runner)
|
||
errors = _flat(databases.values())
|
||
usable = {engine for engine, lines in databases.items() if not lines}
|
||
scanners = [engine for engine in config.scanners() if engine in usable]
|
||
runtime = config.runtime_pass() and RUNTIME_ENGINE in usable
|
||
|
||
sbom_ready = list(targets)
|
||
if runtime or any(config.reads_sbom(engine) for engine in scanners):
|
||
pending = [
|
||
target
|
||
for target in targets
|
||
if target.kind == "host"
|
||
or not all(path.is_file() for path in sbom_paths(layout, target))
|
||
]
|
||
results = run_lanes(
|
||
[[sbom_step(layout, images[SBOM_BUILDER], target)] for target in pending],
|
||
runner,
|
||
config.workers,
|
||
)
|
||
errors += _flat(results)
|
||
failed = {
|
||
target.key for target, lines in zip(pending, results, strict=True) if lines
|
||
}
|
||
sbom_ready = [target for target in targets if target.key not in failed]
|
||
|
||
lanes: list[list[Step]] = []
|
||
if "trivy" in scanners:
|
||
lanes.append(
|
||
[trivy_step(layout, images["trivy"], target, config) for target in targets]
|
||
)
|
||
if "grype" in scanners:
|
||
lanes.extend(
|
||
[grype_step(layout, images["grype"], target, config)]
|
||
for target in targets
|
||
if not config.reads_sbom("grype") or target in sbom_ready
|
||
)
|
||
for target in sbom_ready if runtime else ():
|
||
reduced = runtime_sbom(_read_json(sbom_paths(layout, target)[0]))
|
||
if reduced is None:
|
||
continue
|
||
(layout.work / f"{target.key}.runtime.syft.json").write_text(
|
||
json.dumps(reduced), encoding="utf-8"
|
||
)
|
||
lanes.append([runtime_step(layout, images[RUNTIME_ENGINE], target)])
|
||
if "osv" in scanners and config.reads_sbom("osv"):
|
||
for target in sbom_ready:
|
||
cdx = sbom_paths(layout, target)[1]
|
||
(layout.work / f"{target.key}.osv.cdx.json").write_text(
|
||
json.dumps(osv_sbom(_read_json(cdx))), encoding="utf-8"
|
||
)
|
||
if sbom_ready:
|
||
lanes.append([osv_sbom_step(layout, images["osv"], sbom_ready, config)])
|
||
elif "osv" in scanners:
|
||
lanes.append(
|
||
[
|
||
step
|
||
for target in targets
|
||
for step in osv_image_steps(layout, images["osv"], target, config)
|
||
]
|
||
)
|
||
errors += _flat(run_lanes(lanes, runner, config.workers))
|
||
|
||
findings: list[Finding] = []
|
||
for target in targets:
|
||
for name, parse in (
|
||
("trivy", parse_trivy),
|
||
("grype", parse_grype),
|
||
("runtime.grype", parse_grype),
|
||
):
|
||
report = layout.work / f"{target.key}.{name}.json"
|
||
if report.is_file():
|
||
findings += parse(_read_json(report), target.key)
|
||
report = layout.work / f"{target.key}.osv.json"
|
||
if report.is_file():
|
||
findings += parse_osv(_read_json(report), lambda _path, key=target.key: key)
|
||
batched = layout.work / "osv.json"
|
||
if batched.is_file():
|
||
by_file = {
|
||
f"{WORK}/{target.key}.osv.cdx.json": target.key for target in sbom_ready
|
||
}
|
||
findings += parse_osv(_read_json(batched), by_file.__getitem__)
|
||
return findings, errors
|
||
|
||
|
||
def prune_sboms(layout: Layout, targets: Iterable[Target]) -> None:
|
||
"""Drop the cached SBOMs of images no container runs any more."""
|
||
keep = {path for target in targets for path in sbom_paths(layout, target)}
|
||
for path in layout.sbom.glob("*.json"):
|
||
if path not in keep:
|
||
path.unlink()
|
||
|
||
|
||
def docker_root() -> str:
|
||
return subprocess.run(
|
||
["container", "info", "--format", "{{.DockerRootDir}}"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=True,
|
||
timeout=EXEC_TIMEOUT_SECONDS,
|
||
).stdout.strip()
|
||
|
||
|
||
def vulnerability_check(
|
||
containers: Sequence[Container],
|
||
config: ScanConfig,
|
||
images: Mapping[str, str],
|
||
suppressions: Suppressions,
|
||
cache: Path,
|
||
databases: Path,
|
||
excludes: Sequence[str],
|
||
runner: Runner,
|
||
run: Run,
|
||
) -> tuple[list[Target], list[Verdict], list[str]]:
|
||
"""Scan the running containers and the host.
|
||
|
||
Returns:
|
||
``(targets, verdicts, errors)``: what was scanned, every finding at
|
||
the threshold judged per owner, and the engine runs that failed.
|
||
"""
|
||
targets: list[Target] = []
|
||
if "container" in config.targets:
|
||
targets += container_targets(containers)
|
||
if "host" in config.targets:
|
||
targets.append(host_target(run.host))
|
||
sbom = cache / "sbom"
|
||
sbom.mkdir(parents=True, exist_ok=True)
|
||
work = Path(tempfile.mkdtemp(prefix="run-", dir=cache))
|
||
try:
|
||
layout = Layout(
|
||
runtime=("container", "run"),
|
||
databases=databases,
|
||
sbom=sbom,
|
||
work=work,
|
||
excludes=tuple(
|
||
sorted({*excludes, str(cache), str(databases), docker_root()})
|
||
),
|
||
)
|
||
findings, errors = scan(targets, config, images, layout, runner)
|
||
prune_sboms(
|
||
layout, [target for target in targets if target.kind == "container"]
|
||
)
|
||
finally:
|
||
shutil.rmtree(work)
|
||
judged = verdicts(
|
||
findings,
|
||
{target.key: target for target in targets},
|
||
config,
|
||
suppressions,
|
||
run.started.date(),
|
||
)
|
||
return targets, judged, errors
|
||
|
||
|
||
def main(argv=None) -> int:
|
||
args = parse_args(argv)
|
||
run = Run(
|
||
id=str(uuid.uuid4()),
|
||
started=dt.datetime.now(tz=dt.UTC),
|
||
host=socket.gethostname(),
|
||
environment=args.environment,
|
||
)
|
||
hardened = json.loads(args.hardened)
|
||
try:
|
||
config = scan_config(json.loads(args.scan))
|
||
except ConfigError as exc:
|
||
print(f"❌ {exc}")
|
||
return 1
|
||
try:
|
||
containers = running_containers()
|
||
except (subprocess.TimeoutExpired, subprocess.CalledProcessError) as exc:
|
||
print(f"❌ listing the running containers failed: {exc}")
|
||
return 1
|
||
failures, infos = check(containers, hardened, probe_php)
|
||
targets, judged, errors = vulnerability_check(
|
||
containers,
|
||
config,
|
||
json.loads(args.images),
|
||
suppressions_from_json(json.loads(args.suppressions)),
|
||
Path(args.cache_dir),
|
||
Path(args.database_dir),
|
||
json.loads(args.host_excludes),
|
||
default_runner,
|
||
run,
|
||
)
|
||
found, warned, noted = report_lines(judged)
|
||
for line in infos + noted:
|
||
print(f"ℹ️ {line}")
|
||
for line in warned:
|
||
print(f"⚠️ {line}")
|
||
failed = failures + found + errors
|
||
for line in failed:
|
||
print(f"❌ {line}")
|
||
if failed:
|
||
print(f"🚨 {len(failed)} runtime failure(s).")
|
||
elif warned:
|
||
print(
|
||
f"⚠️ {len(warned)} vulnerability finding(s) mapped to warn; "
|
||
"they do not fail this check."
|
||
)
|
||
else:
|
||
print(
|
||
"✅ PHP is hardened where declared and no engine reports a vulnerability at the threshold."
|
||
)
|
||
outcome = Outcome(
|
||
run=run,
|
||
targets=tuple(targets),
|
||
records=tuple(record(verdict, run) for verdict in judged),
|
||
errors=tuple(errors),
|
||
engines=config.engines,
|
||
failed=bool(failed),
|
||
finished=time.time(),
|
||
)
|
||
events = publish(
|
||
outcome, Path(args.report_dir), Path(args.log_dir), args.metrics_file
|
||
)
|
||
for event in events:
|
||
print(json.dumps(event))
|
||
return 1 if failed else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|