From 59d837f6e748345743b3ce4f30f79289b3605a04 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 18 Aug 2026 21:29:12 +0000 Subject: [PATCH] fix(P03 W0.5): kyverno-json substrate works with real kj (engine + policies + install script) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 == '...' && !`) 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 --- --- adapters/kyverno-json/kyverno_json_engine.py | 325 ++++++++++++++---- adapters/kyverno-json/policies/_smoke.json | 17 +- .../contract/forbid-unknown-fields.json | 19 +- .../contract/require-env-in-enum.json | 17 +- .../policies/contract/require-id-pattern.json | 17 +- .../require-infrastructure-min-1.json | 17 +- .../policies/meta/block-on-any-critical.json | 19 +- .../policies/meta/tagging-rules-agree.json | 33 +- .../plan-json/forbid-iam-wildcard.json | 42 +-- .../plan-json/forbid-plaintext-secrets.json | 21 +- .../plan-json/require-kms-reference.json | 21 +- .../regression/cap-013-adapter-dedup.json | 17 +- .../regression/cap-023-metrics-collector.json | 19 +- .../regression/cap-024-deck-structure.json | 25 +- .../stack-ir/forbid-public-ingress.json | 19 +- .../require-encryption-by-default.json | 44 +-- .../stack-ir/require-tagging-standard.json | 25 +- scripts/install-kyverno-json.sh | 52 ++- 18 files changed, 469 insertions(+), 280 deletions(-) diff --git a/adapters/kyverno-json/kyverno_json_engine.py b/adapters/kyverno-json/kyverno_json_engine.py index 235ab96..183a9e3 100644 --- a/adapters/kyverno-json/kyverno_json_engine.py +++ b/adapters/kyverno-json/kyverno_json_engine.py @@ -1,4 +1,4 @@ -"""Nova KyvernoJsonEngine (REQ-293, v1.25). +"""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 @@ -12,9 +12,10 @@ 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 YAML (not from the -scan result — the result doesn't carry it) and applies it to every -result that policy produces. Default when absent: ``"info"``. +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 @@ -24,6 +25,37 @@ 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 @@ -98,39 +130,41 @@ def _load_policy_severities(policy_dir: Path) -> dict[str, str]: return severities -def _to_pcr(entry: dict, contract_id: str, severity: str) -> dict: - """Translate a kyverno-json scan result entry to a PCR dict.""" - 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, - } +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: @@ -165,6 +199,23 @@ def _error_pcr(contract_id: str, message: str) -> dict: } +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.""" @@ -185,6 +236,9 @@ class KyvernoJsonEngine: 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" @@ -195,7 +249,7 @@ class KyvernoJsonEngine: payload_tmp.close() cmd = [ kj, "scan", - "--policy", str(policy_dir), + "--policy", str(yaml_dir), "--payload", payload_tmp.name, "--output", "json", ] @@ -211,7 +265,7 @@ class KyvernoJsonEngine: f"kyverno-json scan exited {proc.returncode}: {proc.stderr[:200]}", )] try: - out = json.loads(proc.stdout) if proc.stdout.strip() else {} + out = json.loads(proc.stdout) if proc.stdout.strip() else [] except json.JSONDecodeError as e: return [_error_pcr( contract_id, @@ -223,38 +277,185 @@ class KyvernoJsonEngine: os.unlink(payload_tmp.name) except OSError: pass + shutil.rmtree(yaml_dir, ignore_errors=True) - def _translate(self, out: dict, contract_id: str, + def _translate(self, out: Any, contract_id: str, severities: dict[str, str]) -> list[dict]: - results = out.get("results", []) if isinstance(out, dict) else [] - if not isinstance(results, list): - results = [] + # 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 results: + for entry in entries: if not isinstance(entry, dict): continue - policy_name = entry.get("policy", "") or "UNKNOWN" - severity = severities.get(policy_name, SEVERITY_DEFAULT) - pcrs.append(_to_pcr(entry, contract_id, severity)) + 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: - # No results — kyverno-json produced nothing (no match, or - # all policies passed with 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). - pcrs.append({ - "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": "", - }) + 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( diff --git a/adapters/kyverno-json/policies/_smoke.json b/adapters/kyverno-json/policies/_smoke.json index c35e67d..acb38fa 100644 --- a/adapters/kyverno-json/policies/_smoke.json +++ b/adapters/kyverno-json/policies/_smoke.json @@ -12,17 +12,16 @@ "rules": [ { "name": "require-id", - "validate": { - "message": "contract id is required", - "assert": { - "all": [ - { - "check": { - "id": "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))" + "assert": { + "all": [ + { + "check": { + "id": { + "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json b/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json index 9e884af..c1d906a 100644 --- a/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json +++ b/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json @@ -12,18 +12,17 @@ "rules": [ { "name": "no-unknown-fields", - "validate": { - "message": "contract may only contain id, name, environment, infrastructure (schema-allowed fields)", - "assert": { - "all": [ - { - "check": { - "(length(keys(@)) == `4`)": true, - "keys(@)": "(contains(['id','name','environment','infrastructure'], @))" + "assert": { + "all": [ + { + "check": { + "(length(keys(@)) == `4`)": true, + "keys(@)": { + "(contains(['id','name','environment','infrastructure'], @))": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/contract/require-env-in-enum.json b/adapters/kyverno-json/policies/contract/require-env-in-enum.json index 54325c7..661ffcb 100644 --- a/adapters/kyverno-json/policies/contract/require-env-in-enum.json +++ b/adapters/kyverno-json/policies/contract/require-env-in-enum.json @@ -12,17 +12,16 @@ "rules": [ { "name": "env-enum", - "validate": { - "message": "contract.environment must be one of dev, qa, prod, dr", - "assert": { - "all": [ - { - "check": { - "environment": "(contains(['dev','qa','prod','dr'], @))" + "assert": { + "all": [ + { + "check": { + "environment": { + "(contains(['dev','qa','prod','dr'], @))": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/contract/require-id-pattern.json b/adapters/kyverno-json/policies/contract/require-id-pattern.json index b8810ef..9fe0667 100644 --- a/adapters/kyverno-json/policies/contract/require-id-pattern.json +++ b/adapters/kyverno-json/policies/contract/require-id-pattern.json @@ -12,17 +12,16 @@ "rules": [ { "name": "id-pattern", - "validate": { - "message": "contract.id must match ^[a-z][a-z0-9-]{2,5}$ (3-6 char operational acronym)", - "assert": { - "all": [ - { - "check": { - "id": "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))" + "assert": { + "all": [ + { + "check": { + "id": { + "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json b/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json index 862c42d..1b8fe28 100644 --- a/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json +++ b/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json @@ -12,17 +12,16 @@ "rules": [ { "name": "infra-min-1", - "validate": { - "message": "contract.infrastructure must have at least one module entry", - "assert": { - "all": [ - { - "check": { - "infrastructure": "(length(keys(@)) > `0`)" + "assert": { + "all": [ + { + "check": { + "infrastructure": { + "(length(keys(@)) > `0`)": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/meta/block-on-any-critical.json b/adapters/kyverno-json/policies/meta/block-on-any-critical.json index 96d5767..3e084b2 100644 --- a/adapters/kyverno-json/policies/meta/block-on-any-critical.json +++ b/adapters/kyverno-json/policies/meta/block-on-any-critical.json @@ -12,19 +12,14 @@ "rules": [ { "name": "no-critical-fail", - "validate": { - "message": "No PolicyCheckResult in the merged list may have severity: critical + result: fail. The confidence_signal.py hard-override is the defense-in-depth behind this declarative rule (D-119).", - "assert": { - "all": [ - { - "check": { - "~.[]": { - "(severity == 'critical' && result == 'fail')": false - } - } + "assert": { + "all": [ + { + "check": { + "(severity == 'critical' && result == 'fail')": false } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/meta/tagging-rules-agree.json b/adapters/kyverno-json/policies/meta/tagging-rules-agree.json index 71f5154..34e3f0e 100644 --- a/adapters/kyverno-json/policies/meta/tagging-rules-agree.json +++ b/adapters/kyverno-json/policies/meta/tagging-rules-agree.json @@ -12,28 +12,19 @@ "rules": [ { "name": "no-tagging-divergence", - "validate": { - "message": "For every resource, the Checkov NOVA_TAG_NAMING result and the kyverno-json KJ_REQUIRE_TAGGING_STANDARD result must agree. Divergence emits an error PCR (D-118, defense-in-depth against rule drift).", - "assert": { - "all": [ - { - "check": { - "~.[?(ruleId == 'NOVA_TAG_NAMING')]": { - "result->ckv_result": {}, - "($ckv_result == 'fail')": false - } - } - }, - { - "check": { - "~.[?(ruleId == 'KJ_REQUIRE_TAGGING_STANDARD')]": { - "result->kj_result": {}, - "($kj_result == 'fail')": false - } - } + "assert": { + "all": [ + { + "check": { + "(ruleId == 'NOVA_TAG_NAMING' && result == 'fail')": false } - ] - } + }, + { + "check": { + "(ruleId == 'KJ_REQUIRE_TAGGING_STANDARD' && result == 'fail')": false + } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/plan-json/forbid-iam-wildcard.json b/adapters/kyverno-json/policies/plan-json/forbid-iam-wildcard.json index ec67fba..f84a7de 100644 --- a/adapters/kyverno-json/policies/plan-json/forbid-iam-wildcard.json +++ b/adapters/kyverno-json/policies/plan-json/forbid-iam-wildcard.json @@ -12,36 +12,38 @@ "rules": [ { "name": "no-wildcard-action", - "validate": { - "message": "IAM policy Action must not be '*' (ports CKV_AWS_1/40)", - "assert": { - "all": [ - { - "check": { - "planned_values.root_module.~.resources": { - "(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Action, '*'))": false + "assert": { + "all": [ + { + "check": { + "planned_values": { + "root_module": { + "~.resources": { + "(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Action, '*'))": false + } } } } - ] - } + } + ] } }, { "name": "no-wildcard-resource", - "validate": { - "message": "IAM policy Resource must not be '*' (ports CKV_AWS_1/40)", - "assert": { - "all": [ - { - "check": { - "planned_values.root_module.~.resources": { - "(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Resource, '*'))": false + "assert": { + "all": [ + { + "check": { + "planned_values": { + "root_module": { + "~.resources": { + "(type == 'aws_iam_policy' && contains(values.policy_document.Statement[].Resource, '*'))": false + } } } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/plan-json/forbid-plaintext-secrets.json b/adapters/kyverno-json/policies/plan-json/forbid-plaintext-secrets.json index 9df9b0b..9e98898 100644 --- a/adapters/kyverno-json/policies/plan-json/forbid-plaintext-secrets.json +++ b/adapters/kyverno-json/policies/plan-json/forbid-plaintext-secrets.json @@ -12,19 +12,20 @@ "rules": [ { "name": "no-plaintext-db-password", - "validate": { - "message": "aws_db_instance.password must not be a plaintext string (ports CKV_AWS_41/45/46)", - "assert": { - "all": [ - { - "check": { - "planned_values.root_module.~.resources": { - "(type == 'aws_db_instance' && contains(keys(values), 'password') && !contains(['${...}', ''], values.password))": false + "assert": { + "all": [ + { + "check": { + "planned_values": { + "root_module": { + "~.resources": { + "(type == 'aws_db_instance' && contains(keys(values), 'password') && !contains(['${...}', ''], values.password))": false + } } } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/plan-json/require-kms-reference.json b/adapters/kyverno-json/policies/plan-json/require-kms-reference.json index b376807..68c774d 100644 --- a/adapters/kyverno-json/policies/plan-json/require-kms-reference.json +++ b/adapters/kyverno-json/policies/plan-json/require-kms-reference.json @@ -12,19 +12,20 @@ "rules": [ { "name": "kms-by-alias", - "validate": { - "message": "aws_kms_key resources should reference a customer-managed key alias, not inline key material (ports CKV_AWS_7/33)", - "assert": { - "all": [ - { - "check": { - "planned_values.root_module.~.resources": { - "(type == 'aws_kms_key' && !contains(keys(values), 'key_id') && !contains(keys(values), 'kms_key_id'))": false + "assert": { + "all": [ + { + "check": { + "planned_values": { + "root_module": { + "~.resources": { + "(type == 'aws_kms_key' && !contains(keys(values), 'key_id') && !contains(keys(values), 'kms_key_id'))": false + } } } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/regression/cap-013-adapter-dedup.json b/adapters/kyverno-json/policies/regression/cap-013-adapter-dedup.json index 1701640..81af802 100644 --- a/adapters/kyverno-json/policies/regression/cap-013-adapter-dedup.json +++ b/adapters/kyverno-json/policies/regression/cap-013-adapter-dedup.json @@ -12,17 +12,14 @@ "rules": [ { "name": "no-duplicate-adapters", - "validate": { - "message": "Each adapter must be registered exactly once (no duplicate adapter names in the capability inventory). Declarative mirror of core/regression_verify.py CAP-013.", - "assert": { - "all": [ - { - "check": { - "adapters": "(length(duplicates(@)) == `0`)" - } + "assert": { + "all": [ + { + "check": { + "(max(map(&length(@), values(group_by(adapters, &@)))) == `1`)": true } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/regression/cap-023-metrics-collector.json b/adapters/kyverno-json/policies/regression/cap-023-metrics-collector.json index 59d3e58..81a46de 100644 --- a/adapters/kyverno-json/policies/regression/cap-023-metrics-collector.json +++ b/adapters/kyverno-json/policies/regression/cap-023-metrics-collector.json @@ -12,19 +12,16 @@ "rules": [ { "name": "every-metric-has-status", - "validate": { - "message": "Every metric in docs/METRICS.md must declare a status (grounded, derived, or deferred). Declarative mirror of core/regression_verify.py CAP-023.", - "assert": { - "all": [ - { - "check": { - "~.metrics": { - "(contains(['grounded','derived','deferred'], status))": true - } + "assert": { + "all": [ + { + "check": { + "~.metrics": { + "(contains(['grounded','derived','deferred'], status))": true } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/regression/cap-024-deck-structure.json b/adapters/kyverno-json/policies/regression/cap-024-deck-structure.json index be39624..5dae075 100644 --- a/adapters/kyverno-json/policies/regression/cap-024-deck-structure.json +++ b/adapters/kyverno-json/policies/regression/cap-024-deck-structure.json @@ -12,22 +12,19 @@ "rules": [ { "name": "deck-has-4-beats", - "validate": { - "message": "The deck must have the 4-beat arc: Problem, Solution, Proof, Roadmap+Ask. Declarative mirror of core/regression_verify.py CAP-024.", - "assert": { - "all": [ - { - "check": { - "deck.beats": "(length(@) >= `4`)" - } - }, - { - "check": { - "deck.beats": "(contains(@, 'Problem') && contains(@, 'Solution') && contains(@, 'Proof') && contains(@, 'Roadmap+Ask'))" + "assert": { + "all": [ + { + "check": { + "deck": { + "beats": { + "(length(@) >= `4`)": true, + "(contains(@, 'Problem') && contains(@, 'Solution') && contains(@, 'Proof') && contains(@, 'Roadmap+Ask'))": true + } } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json b/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json index 94641fd..e1903a5 100644 --- a/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json +++ b/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json @@ -13,19 +13,16 @@ { "name": "no-public-ingress", "identifier": "id", - "validate": { - "message": "public_ingress: true is not allowed on any resource (v1.0 demo rule, now declarative)", - "assert": { - "all": [ - { - "check": { - "~.resources": { - "(inputs.public_ingress || `false`)": false - } + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(inputs.public_ingress || `false`)": false } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json b/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json index b646097..0b9f969 100644 --- a/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json +++ b/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json @@ -13,43 +13,31 @@ { "name": "s3-encryption", "identifier": "id", - "match": { - "any": [ - {"type": "aws:s3:bucket"} - ] - }, - "validate": { - "message": "S3 buckets must declare encryption config (inputs.bucket_encryption or inputs.kms_key_id)", - "assert": { - "all": [ - { - "check": { - "(contains(keys(inputs), 'bucket_encryption') || contains(keys(inputs), 'kms_key_id'))": true + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(type == 'aws:s3:bucket' && !(contains(keys(inputs), 'bucket_encryption') || contains(keys(inputs), 'kms_key_id')))": false } } - ] - } + } + ] } }, { "name": "ebs-encryption", "identifier": "id", - "match": { - "any": [ - {"type": "aws:ebs:volume"} - ] - }, - "validate": { - "message": "EBS volumes must declare encryption (inputs.encrypted or inputs.kms_key_id)", - "assert": { - "all": [ - { - "check": { - "(contains(keys(inputs), 'encrypted') || contains(keys(inputs), 'kms_key_id'))": true + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(type == 'aws:ebs:volume' && !(contains(keys(inputs), 'encrypted') || contains(keys(inputs), 'kms_key_id')))": false } } - ] - } + } + ] } } ] diff --git a/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json b/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json index 427b38f..2069578 100644 --- a/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json +++ b/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json @@ -13,22 +13,19 @@ { "name": "require-nova-tags", "identifier": "id", - "validate": { - "message": "Every taggable resource must carry nova:owner, nova:contract, nova:environment, nova:cost-center tags", - "assert": { - "all": [ - { - "check": { - "~.resources": { - "(contains(keys(tags || `[]`), 'nova:owner'))": true, - "(contains(keys(tags || `[]`), 'nova:contract'))": true, - "(contains(keys(tags || `[]`), 'nova:environment'))": true, - "(contains(keys(tags || `[]`), 'nova:cost-center'))": true - } + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(contains(keys(inputs.tags || `{}`), 'nova:owner'))": true, + "(contains(keys(inputs.tags || `{}`), 'nova:contract'))": true, + "(contains(keys(inputs.tags || `{}`), 'nova:environment'))": true, + "(contains(keys(inputs.tags || `{}`), 'nova:cost-center'))": true } } - ] - } + } + ] } } ] diff --git a/scripts/install-kyverno-json.sh b/scripts/install-kyverno-json.sh index 1c446cb..f7acdcd 100644 --- a/scripts/install-kyverno-json.sh +++ b/scripts/install-kyverno-json.sh @@ -1,9 +1,18 @@ #!/usr/bin/env bash -# scripts/install-kyverno-json.sh — install the kj CLI (v1.25, REQ-294) +# scripts/install-kyverno-json.sh — install the kj CLI (v1.25, REQ-294; +# fixed v1.26 P3 W0.5). # -# Installs the kyverno-json CLI (`kj`) via `go install` (D-115). The -# binary is a Go project — not a Python package. Cached via the Go -# module cache. +# Installs the kyverno-json CLI via `go install` (D-115). The binary is a +# Go project — not a Python package. Cached via the Go module cache. +# +# v1.26 P3 W0.5 fix: the v1.25 script ran +# go install github.com/kyverno/kyverno-json/cmd/kj@latest +# but the `cmd/kj` path does NOT exist in v0.0.3 — the upstream +# `go install github.com/kyverno/kyverno-json@latest` produces a binary +# named `kyverno-json`, NOT `kj`. The v1.25 invocation failed silently +# (the test suite masked it via `pytest.skip("kj not installed")`). This +# script now installs the real module and symlinks `kyverno-json` → `kj` +# so the engine's `which kj` check passes. # # Usage: bash scripts/install-kyverno-json.sh # Exits 0 on success, 1 if Go is not installed, 2 if `kj version` fails. @@ -11,19 +20,40 @@ set -euo pipefail if ! command -v go >/dev/null 2>&1; then echo "ERROR: Go toolchain not found. Install Go (https://go.dev/dl/) first." >&2 - echo " kyverno-json is a Go binary — `go install` is the upstream-blessed path (D-115)." >&2 + echo " kyverno-json is a Go binary — \`go install\` is the upstream-blessed path (D-115)." >&2 exit 1 fi -echo "Installing kyverno-json CLI (kj) via go install..." GOBIN="${GOBIN:-${HOME}/go/bin}" -go install github.com/kyverno/kyverno-json/cmd/kj@latest + +# Idempotent: if kj is already on PATH and working, short-circuit. +if command -v kj >/dev/null 2>&1 && kj version >/dev/null 2>&1; then + echo "kj installed:" + kj version + echo "DONE" + exit 0 +fi + +echo "Installing kyverno-json CLI (kyverno-json) via go install..." +# The upstream module produces a binary named `kyverno-json` (NOT `kj`). +# The v1.25 `go install .../cmd/kj@latest` path does not exist in v0.0.3. +go install github.com/kyverno/kyverno-json@latest + +# The binary is named `kyverno-json`, not `kj`. Symlink it as `kj` for +# the engine's `which kj` check (kyverno_json_engine.py::_which_kj). +if [ -x "${GOBIN}/kyverno-json" ] && ! command -v kj >/dev/null 2>&1; then + ln -sf "${GOBIN}/kyverno-json" "${GOBIN}/kj" + # If GOBIN not on PATH, try /usr/local/bin so `which kj` resolves. + if ! command -v kj >/dev/null 2>&1; then + ln -sf "${GOBIN}/kyverno-json" /usr/local/bin/kj 2>/dev/null || true + fi +fi if ! command -v kj >/dev/null 2>&1; then - if [ -x "${GOBIN}/kj" ]; then - echo "kj installed to ${GOBIN}/kj (not on PATH)" - echo "add ${GOBIN} to PATH or symlink: ln -s ${GOBIN}/kj /usr/local/bin/kj" - "${GOBIN}/kj" version + if [ -x "${GOBIN}/kyverno-json" ]; then + echo "kyverno-json installed to ${GOBIN}/kyverno-json but 'kj' is not on PATH." >&2 + echo "add ${GOBIN} to PATH or symlink: ln -sf ${GOBIN}/kyverno-json /usr/local/bin/kj" >&2 + "${GOBIN}/kyverno-json" version exit 0 fi echo "ERROR: kj not found on PATH after go install (checked ${GOBIN})." >&2