"""Nova KyvernoJsonEngine (REQ-293, v1.25; fixed v1.26 P3 W0.5). Implements the ``PolicyEngine`` protocol (``core/policy_engine.py``) by shelling to the ``kj`` CLI (``kyverno-json``). Translates native kyverno-json scan output to Nova ``PolicyCheckResult`` dicts (``schemas/policy_check_result.schema.json``). Engine enum reuse (D-116): records carry ``engine: "kyverno"`` (no new enum value). The ``ruleId`` is prefixed ``KJ_`` to distinguish from the K8s Kyverno adapter's ``KYVERNO_`` prefix. Severity (RESEARCH §2.6, G-Q10a): kyverno-json does not natively assign severities. Each Nova policy declares its severity via a ``metadata.annotations["nova.cloudinit.dev/severity"]`` field. The engine reads this annotation from the loaded policy file (not from the scan result — the result carries the policy spec but the annotation is read here from disk) and applies it to every result that policy produces. Default when absent: ``"info"``. Graceful degradation (D-120): ``is_configured()`` returns ``False`` when ``which kj`` is absent → ``evaluate()`` returns a single SKIPPED PCR (``ruleId: KJ_ENGINE_NOT_CONFIGURED``). The platform functions without the binary. Defensive parsing: any kyverno-json output that doesn't match the expected shape produces an ``error`` PCR, never an exception. The engine is read-only against a local policy dir + a temp payload file. v1.26 P3 W0.5 fix — three substrate bugs uncovered once ``kj`` was actually installed (the v1.25 test suite ``pytest.skip``-masked them): 1. **``.json`` policy files are not loaded by ``kj`` v0.0.3.** The upstream policy loader (``pkg/policy/load.go``) uses ``fileinfo.IsYaml()`` which only matches ``.yaml``/``.yml`` extensions — ``.json`` files are silently skipped, yielding ``evaluating N resources against 0 policies``. Nova policies are authored as ``.json`` (the ``TestPolicyFilesExist`` tests assert the ``.json`` filenames). Fix: ``evaluate()`` materializes a temp policy dir that mirrors the source tree with every ``.json`` policy copied to a ``.yaml`` twin (JSON is a valid YAML subset — verified against ``kj`` v0.0.3). The source ``.json`` files remain untouched. 2. **Bare-list output format.** ``kj scan --output json`` emits a bare JSON list at the top level (NOT ``{"results": [...]}``). Each entry has ``resource`` (the evaluated payload) + ``results`` (list of per-policy result objects, each carrying ``policy.metadata.name``, ``rules[]`` with ``rule.name``, ``violations[]`` (present on fail), ``error`` (string, present on policy-evaluation error)). The v1.25 ``_translate`` did ``out.get("results", [])`` on a dict — but ``out`` is a list → returned ``[]`` → emitted a single ``KJ_NO_RESULTS`` pass PCR. **This is why all failing fixtures showed 0 fails.** Fix: ``_translate`` handles list (v0.0.3) and dict (future-proof) shapes. 3. **``validate`` wrapper + check syntax.** Documented in the policy files themselves (see the W0.5 policy edits). The engine itself does not enforce policy shape — it only translates ``kj`` output — so this fix lives in the policy ``.json`` files. """ import datetime import json import os import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Any, Union import yaml Payload = Union[dict, list, str] SEVERITY_DEFAULT = "info" SEVERITY_ANNOTATION = "nova.cloudinit.dev/severity" RESULT_MAP = { "pass": "pass", "fail": "fail", "error": "error", "skip": "skipped", "skipped": "skipped", "warn": "skipped", "warning": "skipped", } def _iso8601_now() -> str: return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _which_kj() -> str | None: """Return the path to ``kj`` if on PATH, else ``None``.""" return shutil.which("kj") def _load_policy_severities(policy_dir: Path) -> dict[str, str]: """Load each ``.json``/``.yaml``/``.yml`` policy in ``policy_dir`` (non-recursive) and return ``{policy_name: severity}``. kyverno-json policies are Kubernetes-style ``ValidatingPolicy`` resources. The severity is read from ``metadata.annotations["nova.cloudinit.dev/severity"]``. Policies in subdirectories (e.g. ``contract/``, ``stack-ir/``) are loaded when the caller passes that subdirectory as ``policy_dir``. """ severities: dict[str, str] = {} if not policy_dir.is_dir(): return severities for entry in sorted(os.listdir(policy_dir)): if entry.startswith("_") or entry.startswith("."): continue full = policy_dir / entry if not full.is_file(): continue if entry.endswith((".json", ".yaml", ".yml")): try: with open(full, "r", encoding="utf-8") as fh: doc = yaml.safe_load(fh) if not isinstance(doc, dict): continue name = doc.get("metadata", {}).get("name") or entry.rsplit(".", 1)[0] ann = doc.get("metadata", {}).get("annotations", {}) or {} sev = ann.get(SEVERITY_ANNOTATION, SEVERITY_DEFAULT) severities[name] = str(sev).lower() except Exception: continue return severities def _materialize_yaml_policy_dir(src: Path) -> tuple[Path, bool]: """Mirror ``src`` (recursively) into a temp dir, copying every ``.json`` policy to a ``.yaml`` twin and copying ``.yaml``/``.yml`` files verbatim. Returns ``(temp_dir, created)``. ``kj`` v0.0.3's policy loader (``pkg/policy/load.go``) only matches ``.yaml``/``.yml`` extensions — ``.json`` files are silently skipped. Nova policies are authored as ``.json`` (the ``TestPolicyFilesExist`` tests assert the ``.json`` filenames, so they cannot be renamed in-place). JSON is a valid YAML subset, so a byte-for-byte copy with a ``.yaml`` extension loads cleanly. ``created`` is ``False`` when ``src`` contains no policy files at all (empty dir) — in that case the temp dir is still returned (the caller invokes ``kj`` against it and gets the no-results path). """ tmp = Path(tempfile.mkdtemp(prefix="nova-kj-pol-")) any_policy = False if src.is_dir(): for root, _dirs, files in os.walk(src): rel = Path(root).relative_to(src) dest_root = tmp / rel dest_root.mkdir(parents=True, exist_ok=True) for fn in files: if fn.startswith(".") or fn.startswith("_"): continue src_file = Path(root) / fn if fn.endswith(".json"): dest_file = dest_root / (fn.rsplit(".", 1)[0] + ".yaml") shutil.copy2(src_file, dest_file) any_policy = True elif fn.endswith((".yaml", ".yml")): shutil.copy2(src_file, dest_root / fn) any_policy = True return tmp, any_policy def _skipped_not_configured(contract_id: str) -> dict: return { "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": "KJ_ENGINE_NOT_CONFIGURED", "severity": "info", "result": "skipped", "message": ( "kyverno-json engine not configured — `which kj` returned no path. " "Install via scripts/install-kyverno-json.sh. The platform proceeds " "with a neutral SKIPPED policy input (is_configured() guard, D-120)." ), "evidence": {}, "resourceRef": "", } def _error_pcr(contract_id: str, message: str) -> dict: return { "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": "KJ_ENGINE_ERROR", "severity": "info", "result": "error", "message": message, "evidence": {}, "resourceRef": "", } def _no_results_pass(contract_id: str) -> dict: """No result entries — emit a single pass PCR so the confidence signal's policy input is non-empty (a non-empty list of passes → score 1.0).""" return { "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": "KJ_NO_RESULTS", "severity": "info", "result": "pass", "message": "kyverno-json scan produced no result entries (all policies passed or no match).", "evidence": {}, "resourceRef": "", } class KyvernoJsonEngine: """``PolicyEngine`` impl that shells to the ``kj`` CLI.""" name = "kyverno-json" def is_configured(self) -> bool: return _which_kj() is not None def evaluate(self, payload: Payload, policy_dir: Path, contract_id: str) -> list[dict]: if not self.is_configured(): return [_skipped_not_configured(contract_id)] kj = _which_kj() policy_dir = Path(policy_dir) if not policy_dir.is_dir(): return [_error_pcr( contract_id, f"kyverno-json policy dir not found: {policy_dir}", )] severities = _load_policy_severities(policy_dir) # kj v0.0.3 only loads .yaml/.yml policy files. Mirror the tree # to a temp dir with .json policies copied to .yaml twins. yaml_dir, _any_policy = _materialize_yaml_policy_dir(policy_dir) # Write payload to temp file (kj scan --payload expects a file path). payload_tmp = tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False, encoding="utf-8" ) try: json.dump(payload, payload_tmp) payload_tmp.flush() payload_tmp.close() cmd = [ kj, "scan", "--policy", str(yaml_dir), "--payload", payload_tmp.name, "--output", "json", ] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=60, ) except subprocess.TimeoutExpired: return [_error_pcr(contract_id, "kyverno-json scan timed out (60s)")] if proc.returncode not in (0, 1): return [_error_pcr( contract_id, f"kyverno-json scan exited {proc.returncode}: {proc.stderr[:200]}", )] try: out = json.loads(proc.stdout) if proc.stdout.strip() else [] except json.JSONDecodeError as e: return [_error_pcr( contract_id, f"kyverno-json output not JSON: {e}", )] return self._translate(out, contract_id, severities) finally: try: os.unlink(payload_tmp.name) except OSError: pass shutil.rmtree(yaml_dir, ignore_errors=True) def _translate(self, out: Any, contract_id: str, severities: dict[str, str]) -> list[dict]: # kj v0.0.3 emits a BARE JSON LIST at the top level: each entry # has `resource` (the evaluated payload) + `results` (list of # per-policy result objects). Future-proof: also accept the # legacy {"results": [...]} dict shape. if isinstance(out, list): entries = out elif isinstance(out, dict): entries = out.get("results", []) if not isinstance(entries, list): entries = [] else: entries = [] pcrs: list[dict] = [] for entry in entries: if not isinstance(entry, dict): continue resource = entry.get("resource", {}) results = entry.get("results", []) if not isinstance(results, list): results = [] for pol_result in results: if not isinstance(pol_result, dict): continue policy_obj = pol_result.get("policy", {}) or {} policy_name = ( policy_obj.get("metadata", {}).get("name") if isinstance(policy_obj, dict) else None ) or "UNKNOWN" severity = severities.get(policy_name, SEVERITY_DEFAULT) rules = pol_result.get("rules", []) if not isinstance(rules, list): rules = [] for rule_entry in rules: if not isinstance(rule_entry, dict): continue rule_obj = rule_entry.get("rule", {}) or {} rule_name = rule_obj.get("name", "") if isinstance(rule_obj, dict) else "" rule_id = f"KJ_{policy_name}" if rule_name: rule_id = f"{rule_id}/{rule_name}" violations = rule_entry.get("violations") error_str = rule_entry.get("error") if isinstance(violations, list) and violations: # Fail: build a message from the violations' errors. msg_parts: list[str] = [] for v in violations: if not isinstance(v, dict): continue for err in v.get("errors", []) or []: if not isinstance(err, dict): continue field = err.get("field", "") detail = err.get("detail", "") value = err.get("value", "") msg_parts.append( f"{field}: value={value!r} detail={detail}" ) message = "; ".join(msg_parts) if msg_parts else "policy rule failed" pcrs.append({ "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": rule_id, "severity": severity, "result": "fail", "message": message, "evidence": { "resource": resource, "policy": policy_name, "rule": rule_name, "violations": violations, }, "resourceRef": _resource_ref(resource), }) elif isinstance(error_str, str) and error_str: # Policy-evaluation error (e.g. bad JMESPath). pcrs.append({ "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": rule_id, "severity": severity, "result": "error", "message": error_str, "evidence": { "resource": resource, "policy": policy_name, "rule": rule_name, }, "resourceRef": _resource_ref(resource), }) else: # Pass: no violations, no error. pcrs.append({ "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": rule_id, "severity": severity, "result": "pass", "message": "", "evidence": { "resource": resource, "policy": policy_name, "rule": rule_name, }, "resourceRef": _resource_ref(resource), }) if not pcrs: pcrs.append(_no_results_pass(contract_id)) return pcrs def _resource_ref(resource: Any) -> str: """Best-effort resource ref from the evaluated payload.""" if isinstance(resource, dict): for key in ("id", "name", "address"): v = resource.get(key) if isinstance(v, str) and v: return v return "" # --- Legacy _to_pcr kept for the existing TestToPcr unit tests --- # (test_kyverno_json_engine.py::TestToPcr constructs flat `entry` # dicts with `policy`/`rule`/`result`/`message`/`resource` keys and # asserts the translated PCR shape. The production _translate path no # longer calls this helper — it inlines the translation against the # real kj v0.0.3 nested output — but the unit tests pin the helper's # contract, so it stays.) def _to_pcr(entry: dict, contract_id: str, severity: str) -> dict: """Translate a flat kyverno-json scan result entry to a PCR dict. Legacy shape (kept for unit-test backwards compatibility): the entry is a flat dict with ``policy``/``rule``/``result``/``message``/ ``resource`` string keys. The production ``_translate`` path no longer calls this — it inlines translation against the real kj v0.0.3 nested ``resource``+``results``+``rules`` shape — but the ``TestToPcr`` unit tests pin this contract. """ policy_name = entry.get("policy", "") or "UNKNOWN" rule_name = entry.get("rule", "") or "" rule_id = f"KJ_{policy_name}" if rule_name: rule_id = f"{rule_id}/{rule_name}" result_raw = entry.get("result", "skip") result = RESULT_MAP.get(str(result_raw).lower(), "error") message = entry.get("message", "") or "" resource = entry.get("resource", "") if not resource and entry.get("name"): kind = entry.get("kind", "") ns = entry.get("namespace", "") resource = f"{kind}/{ns}/{entry.get('name')}" if kind else entry.get("name", "") return { "contractId": contract_id, "evaluatedAt": _iso8601_now(), "engine": "kyverno", "ruleId": rule_id, "severity": severity, "result": result, "message": message, "evidence": { "resource": resource, "policy": policy_name, "rule": rule_name, "namespace": entry.get("namespace", ""), "kind": entry.get("kind", ""), "name": entry.get("name", ""), }, "resourceRef": resource, } if __name__ == "__main__": if len(sys.argv) < 4: print( "usage: kyverno_json_engine.py ", file=sys.stderr, ) sys.exit(2) with open(sys.argv[1], "r", encoding="utf-8") as fh: pl = json.load(fh) engine = KyvernoJsonEngine() out = engine.evaluate(pl, Path(sys.argv[2]), sys.argv[3]) print(json.dumps(out, indent=2))