feat(vuln): scanner service and fixture-driven L1 tests
Stdlib-only scanner per docs/17: Kubernetes API inventory, Trivy runner, persisted state, schema-validated report.json, strict acceptances parser, server-rendered page, aggregate metrics, internal API, and a scan loop that stays live while scanning. L1 tests under conformance/tests/l1-rendering/vuln-scanner-* run the actual modules against recorded fixtures with a fake Trivy runner. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4c3a284cad
commit
50ec2297ee
32 files changed
+3951
No files matched your search
@@ -0,0 +1,141 @@
|
||||
"""R-VULN-08 acceptances: a strict parser for exactly the spec's flat list, no YAML library.
|
||||
|
||||
- id: CVE-2026-00000
|
||||
image: registry/repository # exact match; omit only for platform-wide
|
||||
reason: not reachable - feature disabled
|
||||
expires: 2027-02-01 # mandatory, at most 180 days out
|
||||
|
||||
Fail-safe: a malformed entry is ignored and reported, so it can never hide a finding. Anything the
|
||||
parser cannot place (bad indentation, a stray line) invalidates the whole file for the same reason.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
from state import utcnow
|
||||
|
||||
MAX_DAYS = 180
|
||||
_KEYS = ("id", "image", "reason", "expires")
|
||||
_ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$")
|
||||
_FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
||||
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_FORBIDDEN_START = tuple("[{&*!|>%@`")
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""(value, None) or (None, error) for an unquoted or simply-quoted YAML scalar."""
|
||||
value = (raw or "").strip()
|
||||
if value[:1] in ("'", '"'):
|
||||
quote = value[0]
|
||||
end = value.find(quote, 1)
|
||||
if end == -1 or "\\" in value[:end]:
|
||||
return None, "bad quoting"
|
||||
rest = value[end + 1:].strip()
|
||||
if rest and not rest.startswith("#"):
|
||||
return None, "text after the closing quote"
|
||||
return value[1:end], None
|
||||
if value.startswith(_FORBIDDEN_START):
|
||||
return None, "only plain scalars are allowed"
|
||||
comment = re.search(r"\s#", value)
|
||||
if comment:
|
||||
value = value[:comment.start()].rstrip()
|
||||
return value, None
|
||||
|
||||
|
||||
def parse_text(source, today=None):
|
||||
"""(valid entries, error strings). Entries carry id, reason, expires (date) and optional image."""
|
||||
today = today or utcnow().date()
|
||||
errors = []
|
||||
raw_entries = []
|
||||
current = None
|
||||
for lineno, line in enumerate(source.splitlines(), 1):
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
entry = _ENTRY.match(line)
|
||||
field = _FIELD.match(line)
|
||||
if line.strip().split("#", 1)[0].strip() == "[]" and not raw_entries and current is None:
|
||||
continue
|
||||
if entry:
|
||||
current = {"line": lineno, "fields": {}, "errors": []}
|
||||
raw_entries.append(current)
|
||||
key, raw = entry.group(1), entry.group(2)
|
||||
elif field and current is not None:
|
||||
key, raw = field.group(1), field.group(2)
|
||||
else:
|
||||
return [], ["line " + str(lineno) + ": not a flat acceptance entry; the whole file is ignored"]
|
||||
value, problem = _scalar(raw)
|
||||
if key not in _KEYS:
|
||||
current["errors"].append("unknown key " + key)
|
||||
elif problem:
|
||||
current["errors"].append(key + ": " + problem)
|
||||
elif key in current["fields"]:
|
||||
current["errors"].append("duplicate key " + key)
|
||||
else:
|
||||
current["fields"][key] = value
|
||||
entries = []
|
||||
for raw in raw_entries:
|
||||
problems = list(raw["errors"]) + _validate(raw["fields"], today)
|
||||
if problems:
|
||||
errors.append("entry at line " + str(raw["line"]) + ": " + "; ".join(problems))
|
||||
continue
|
||||
fields = raw["fields"]
|
||||
entries.append({"id": fields["id"], "image": fields.get("image") or None, "reason": fields["reason"],
|
||||
"expires": date.fromisoformat(fields["expires"])})
|
||||
return entries, errors
|
||||
|
||||
|
||||
def _validate(fields, today):
|
||||
problems = []
|
||||
if not _ID.match(fields.get("id") or ""):
|
||||
problems.append("id is missing or malformed")
|
||||
if not fields.get("reason"):
|
||||
problems.append("reason is mandatory")
|
||||
if "image" in fields and not fields["image"]:
|
||||
problems.append("image is empty")
|
||||
expires = fields.get("expires") or ""
|
||||
if not _DATE.match(expires):
|
||||
problems.append("expires is mandatory and must be YYYY-MM-DD")
|
||||
return problems
|
||||
try:
|
||||
when = date.fromisoformat(expires)
|
||||
except ValueError:
|
||||
problems.append("expires is not a real date")
|
||||
return problems
|
||||
if when > today + timedelta(days=MAX_DAYS):
|
||||
problems.append("expires is more than " + str(MAX_DAYS) + " days out")
|
||||
return problems
|
||||
|
||||
|
||||
def load(path, today=None):
|
||||
"""Entries and errors from a file. An unreadable file is an error, never silently empty."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
source = handle.read(1024 * 1024 + 1)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
return [], ["acceptances file unreadable: " + type(exc).__name__]
|
||||
if len(source) > 1024 * 1024:
|
||||
return [], ["acceptances file exceeds 1 MiB"]
|
||||
return parse_text(source, today)
|
||||
|
||||
|
||||
def match(entries, finding_id, image, today=None):
|
||||
"""The unexpired acceptance covering a finding, if any. Expiry day itself still counts."""
|
||||
today = today or utcnow().date()
|
||||
best = None
|
||||
for entry in entries:
|
||||
if entry["id"] != finding_id or entry["expires"] < today:
|
||||
continue
|
||||
if entry["image"] is not None and entry["image"] != image:
|
||||
continue
|
||||
if best is None or entry["expires"] > best["expires"]:
|
||||
best = entry
|
||||
return best
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
found, problems = load(sys.argv[1])
|
||||
for problem in problems:
|
||||
print("INVALID: " + problem, file=sys.stderr)
|
||||
print(str(len(found)) + " valid entries")
|
||||
sys.exit(1 if problems else 0)
|
||||
Reference in new issue
Block a user