59d837f6e7
The v1.25 kyverno-json engine adapter and policies were authored but never
validated against the real `kj` binary — the test suite
`pytest.skip("kj not installed")` when `kj` was absent, masking the bug.
With `kj` v0.0.3 now installed, the 3 failing-fixture tests
(stack-ir/plan-json/regression) showed 0 fails (all passed falsely). Root
causes (3 substrate bugs) and fixes:
1. ENGINE — bare-list output format. `kj scan --output json` emits a bare
JSON LIST at the top level (NOT `{"results": [...]}`); each entry has
`resource` + `results[].rules[]` with `violations[]` (fail) / `error`
string (eval error) / neither (pass). The v1.25 `_translate` did
`out.get("results", [])` on a dict → `out` is a list → returned `[]` →
emitted a single KJ_NO_RESULTS pass PCR. Rewrote `_translate` to parse
the real v0.0.3 nested shape (policy.metadata.name, rule.name,
violations[].errors[].field/detail/value). Future-proofs to also accept
the legacy dict shape. Preserves RESULT_MAP, severity-from-annotation,
is_configured(), _skipped_not_configured, _error_pcr, the temp-file
payload write, and the subprocess invocation.
2. ENGINE — `.json` policies not loaded by `kj`. The upstream loader
(pkg/policy/load.go) uses fileinfo.IsYaml() which only matches
`.yaml`/`.yml` — `.json` files are silently skipped (0 policies).
Nova policies are authored as `.json` (TestPolicyFilesExist asserts the
filenames). Added `_materialize_yaml_policy_dir`: mirrors the source
tree to a temp dir, copying every `.json` policy to a `.yaml` twin
(JSON is a valid YAML subset, verified against kj v0.0.3). Source
`.json` files remain untouched.
3. POLICIES — `validate` wrapper + check syntax. Removed the `validate`
wrapper from all 16 policies (kj v0.0.3 ignores `validate`-wrapped
rules — `assert` goes directly under the rule). Fixed the check syntax:
a check entry is `expression: expected_value` (e.g.
`(regex_match(..., @)): true`), not `field: (expression)` (which
compared a bool to nothing → "types not comparable"). For per-resource
checks over stack-IR/plan-JSON, `~.resources` (descendant anchor) is
required for per-element iteration; a plain path applies to the whole
array. For type-scoped rules (s3/ebs encryption, iam/db/kms), the type
guard is folded into the expression (`type == '...' && !<has-prop>`)
so non-matching resources short-circuit to false. cap-013 dedup uses
`max(map(&length(@), values(group_by(adapters, &@)))) == `1`` (no
`duplicates` JMESPath fn exists). Preserved all policy metadata
(apiVersion, kind, metadata.name, severity + title annotations) —
TestPolicyValidity/TestPolicyFilesExist still pass.
INSTALL SCRIPT — the v1.25 `go install .../cmd/kj@latest` failed: the
`cmd/kj` path does not exist in v0.0.3 (upstream produces a binary named
`kyverno-json`). Fixed to `go install github.com/kyverno/kyverno-json@latest`
+ symlink `kyverno-json` → `kj` (GOBIN and /usr/local/bin fallbacks).
Idempotent: short-circuits when `kj` is already on PATH and working.
Verification: `which kj` → /usr/local/bin/kj; `kj version` → v0.0.3.
test_kyverno_json_engine + test_stack_ir_policies + test_plan_json_policies
+ test_meta_policies + test_regression_policies: 36 passed, 0 skips
(_require_kj no longer skips). Full suite (excluding pre-existing hang in
test_verify_regression_mode.py): 776 passed, 6 failed — all 6 failures are
pre-existing (confirmed by stashing this commit's diff and re-running);
the only in-scope-acceptable failure is
test_module_standards.py::test_all_l1_have_required_files (dynamodb
extension drift, data-engineer's later wave).
---ci---
project: acdl
phase: 3
milestone: v1.26
status: execute
wave: W0.5
---
470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""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_<policy_name>`` 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 <payload.json> <policy_dir> <contract-id>",
|
|
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)) |