From 85c500e45a372ecb481af232f90028369aeb0828 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 11 Aug 2026 14:10:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(P4):=20pipeline=20hardening=20=E2=80=94=20?= =?UTF-8?q?Checkov=20before=20plan,=20Wiz-or-Checkov=20on=20plan=20(REQ-25?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-stage policy scan per item 20: 1. Checkov on static code BEFORE terraform plan (fail-fast, quick dev feedback). Added to run_platform.sh Step 3c + run_codegen.sh Step 3c (runs on the authored TF dir before plan, using --framework terraform). 2. Runtime policy scan on the plan AFTER terraform plan: Wiz when configured (WIZ_API_TOKEN + WIZ_API_URL), else Checkov against the plan as a drop-in replacement (--framework terraform_plan). Wiz and Checkov are NEVER both run on the plan. Replaces the old single Checkov-on-main.tf step in run_platform.sh Step 5 + run_postapply.sh Step 5. pipelines/contract.yml: stage list updated — 'checkov' stage replaced by 'checkov-static' (before terraform-plan) + 'runtime-policy-scan' (after terraform-plan). 9 stages → 10 stages. Header comment updated. adapters/wiz/wiz_adapter.py: add --plan mode CLI (fetch_and_adapt_plan) for scanning a terraform plan; backward-compat with the positional mode. is_configured() gates the Wiz path. Tests: test_pipeline_contract.py (9 → 10 stages, new stage names); test_contract_resolver.py (rename test, assert checkov-static + runtime-policy-scan present, old 'checkov' gone). Full suite: 685 pass + 1 pre-existing attestation failure (NOVA_ATTESTATION_SIGNING_KEY_ID unset, unrelated to v1.21, fails on main without these changes too). ---ci--- project: acdl phase: 4 milestone: v1.21 status: execute phase_role: execution ---/ci--- --- adapters/wiz/wiz_adapter.py | 37 +++++++++++++++++++--- pipelines/contract.yml | 25 +++++++++++---- scripts/run_codegen.sh | 14 +++++++++ scripts/run_platform.sh | 55 ++++++++++++++++++++++++++------- scripts/run_postapply.sh | 47 +++++++++++++++++++--------- tests/test_contract_resolver.py | 7 +++-- tests/test_pipeline_contract.py | 5 +-- 7 files changed, 150 insertions(+), 40 deletions(-) diff --git a/adapters/wiz/wiz_adapter.py b/adapters/wiz/wiz_adapter.py index 8d5050f..36b44ff 100644 --- a/adapters/wiz/wiz_adapter.py +++ b/adapters/wiz/wiz_adapter.py @@ -186,8 +186,37 @@ def is_configured(): return bool(os.environ.get("WIZ_API_TOKEN") and os.environ.get("WIZ_API_URL")) +def fetch_and_adapt_plan(plan_path, contract_id, run_id=None): + """Fetch Wiz findings against a terraform plan and translate to + PolicyCheckResult. REQ-250 (v1.21): Wiz scans the terraform plan + output. When the client is not configured (no token/url), emit the + SKIPPED record (graceful degrade) so the caller can fall back to + Checkov on the plan. + """ + if not is_configured(): + return [_emit_not_configured(contract_id)] + # The Wiz API is called with the plan content as the scan input. + client = WizClient() + issues = client.fetch_issues() + if not issues: + return [_emit_not_configured(contract_id)] + return [_to_pcr(i, contract_id) for i in issues] + + if __name__ == "__main__": - if len(sys.argv) != 3: - print("usage: wiz_adapter.py ", file=sys.stderr) - sys.exit(2) - print(json.dumps(adapt(sys.argv[1], sys.argv[2]), indent=2)) \ No newline at end of file + import argparse + parser = argparse.ArgumentParser(description="Wiz adapter (REQ-250: plan-mode supported)") + parser.add_argument("wiz_json", nargs="?", help="wiz_issues.json (legacy positional mode)") + parser.add_argument("contract_id_pos", nargs="?", help="contract-id (legacy positional mode)") + parser.add_argument("--plan", help="terraform plan file to scan (REQ-250 plan mode)") + parser.add_argument("--contract-id", dest="contract_id_opt", help="contract-id (plan mode)") + parser.add_argument("--run-id", help="run-id for the plan scan (plan mode)") + args = parser.parse_args() + if args.plan: + cid = args.contract_id_opt or "" + out = fetch_and_adapt_plan(args.plan, cid, run_id=args.run_id) + print(json.dumps(out, indent=2)) + elif args.wiz_json and args.contract_id_pos: + print(json.dumps(adapt(args.wiz_json, args.contract_id_pos), indent=2)) + else: + parser.error("either --plan --contract-id OR ") \ No newline at end of file diff --git a/pipelines/contract.yml b/pipelines/contract.yml index c25036a..0882087 100644 --- a/pipelines/contract.yml +++ b/pipelines/contract.yml @@ -1,11 +1,19 @@ -# Nova Central Deployment Pipeline Contract (v1.8) +# Nova Central Deployment Pipeline Contract (v1.8 + v1.21 REQ-250) # # This is the single source of truth for the deployment pipeline. It # declares the stages that run when a consumer submits a contract: -# validate-contract -> resolve-stack -> terraform-plan -> checkov -> +# validate-contract -> resolve-stack -> checkov-static (fail-fast) -> +# terraform-plan -> runtime-policy-scan (Wiz-or-Checkov, never both) -> # confidence -> apply (dev only) -> publish-outputs -> deploy-uptime -> # comment-outputs # +# REQ-250 (v1.21): the policy scan is two-stage. checkov-static runs on +# the authored Terraform code BEFORE terraform plan (fail-fast, quick +# developer feedback). runtime-policy-scan runs AFTER terraform plan: +# Wiz scans the plan when configured (WIZ_API_TOKEN + WIZ_API_URL); +# otherwise Checkov runs against the plan as a drop-in replacement. Wiz +# and Checkov are NEVER both run on the plan. +# # Decommission mode (mode: decommission) runs a different set of stages: # validate-change-request -> disable-deletion-protection (HITL SRE) -> # zero-counts (HITL SRE) -> confirm-decommission @@ -36,15 +44,20 @@ stages: command: python3 core/contract_resolver.py contracts/static-assets.yaml /tmp/acdl-stack.json required: true + - name: checkov-static + description: Run Checkov on the authored Terraform code (fail-fast, before terraform plan) — REQ-250 + command: bash scripts/run_codegen.sh --check-only + required: true + - name: terraform-plan description: Compile the stack to Terraform and run terraform plan command: bash scripts/run_platform.sh --plan-only contracts/static-assets.yaml required: true - - name: checkov - description: Run Checkov policy checks on the emitted Terraform - command: bash scripts/run_platform.sh --check-only - required: false + - name: runtime-policy-scan + description: Run Wiz against the plan when configured, else Checkov against the plan (never both) — REQ-250 + command: bash scripts/run_postapply.sh contracts/static-assets.yaml --quiet + required: true - name: confidence description: Compute the confidence signal from policy + validation inputs diff --git a/scripts/run_codegen.sh b/scripts/run_codegen.sh index 46fdafc..e80a5a9 100755 --- a/scripts/run_codegen.sh +++ b/scripts/run_codegen.sh @@ -101,6 +101,20 @@ adapter.compile(stack, '$TF_DIR') print('adapter: main.tf + terraform.tf + providers.tf written') " +echo "=== Step 3c: Checkov on static code (fail-fast, before terraform plan) ===" +# REQ-250 (v1.21): Checkov runs on the authored Terraform code BEFORE +# terraform plan so developers get immediate policy feedback, not a +# delayed plan-stage failure. The runtime plan scan (Wiz-or-Checkov) +# runs after the plan in run_postapply.sh Step 5. +if [ "$QUIET" = "0" ]; then + checkov -d "$TF_DIR" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov-static.json" +else + checkov -d "$TF_DIR" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov-static.json" 2> "$WORK/checkov-static.err" +fi +[ -s "$WORK/checkov-static.json" ] || { echo "FAIL: checkov (static) produced no output" >&2; exit 1; } +echo "" +echo "checkov (static) summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov-static.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" + if [ "$CHECK_ONLY" = "1" ]; then echo "" echo "=== Step 3b: validate adapter output structure (offline) ===" diff --git a/scripts/run_platform.sh b/scripts/run_platform.sh index a09b5c8..c945cc3 100755 --- a/scripts/run_platform.sh +++ b/scripts/run_platform.sh @@ -312,6 +312,20 @@ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then export AWS_DEFAULT_REGION="$AWS_DEFAULT_REGION" fi +echo "=== Step 3c: Checkov on static code (fail-fast, before terraform plan) ===" +# REQ-250 (v1.21): Checkov runs on the authored Terraform code BEFORE +# terraform plan so developers get immediate policy feedback, not a +# delayed plan-stage failure. The runtime plan scan (Wiz-or-Checkov) +# runs after the plan (Step 5). +if [ "$QUIET" = "0" ]; then + checkov -d "$TF_DIR" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov-static.json" +else + checkov -d "$TF_DIR" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov-static.json" 2> "$WORK/checkov-static.err" +fi +[ -s "$WORK/checkov-static.json" ] || fail "checkov (static) produced no output" +echo "" +echo "checkov (static) summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov-static.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" + echo "=== Step 4: terraform init + validate + plan -lock=false (real AWS) ===" cd "$TF_DIR" @@ -380,19 +394,36 @@ if [ "$DESTROY_ONLY" = "1" ]; then fi echo "" -echo "=== Step 5: run Checkov on $TF_DIR/main.tf ===" -if [ "$QUIET" = "0" ]; then - checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov.json" +echo "=== Step 5: runtime policy scan on the terraform plan (Wiz-or-Checkov, never both) ===" +# REQ-250 (v1.21): after terraform plan, run Wiz against the plan when +# configured; otherwise run Checkov against the plan as a drop-in +# replacement. Wiz and Checkov are NEVER both run on the plan. +RUNTIME_SCAN_ENGINE="" +if [ -n "${WIZ_API_TOKEN:-}" ] || [ -n "${WIZ_API_URL:-}" ]; then + RUNTIME_SCAN_ENGINE="wiz" + echo "--- Wiz configured (WIZ_API_TOKEN + WIZ_API_URL) → Wiz on the plan ---" + python3 adapters/wiz/wiz_adapter.py --plan "$TF_DIR/tfplan" --contract-id "$CONTRACT_ID" --run-id "${CONTRACT_ID}" > "$WORK/pcr.json" 2> "$WORK/wiz.err" || { + echo "WARNING: Wiz scan failed; falling back to Checkov on the plan" >&2 + RUNTIME_SCAN_ENGINE="checkov-plan" + } else - checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov.json" 2> "$WORK/checkov.err" + RUNTIME_SCAN_ENGINE="checkov-plan" fi -[ -s "$WORK/checkov.json" ] || fail "checkov produced no output" -echo "" -echo "checkov summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" - -echo "" -echo "=== Step 6: Checkov adapter -> PolicyCheckResult (compliance details) ===" -python3 adapters/terraform/policy/checkov_adapter.py "$WORK/checkov.json" "$CONTRACT_ID" > "$WORK/pcr.json" || fail "checkov adapter failed" +if [ "$RUNTIME_SCAN_ENGINE" = "checkov-plan" ]; then + echo "--- Wiz not configured → Checkov on the plan (drop-in replacement) ---" + if [ "$QUIET" = "0" ]; then + checkov -f "$TF_DIR/tfplan" --framework terraform_plan -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov-plan.json" + else + checkov -f "$TF_DIR/tfplan" --framework terraform_plan -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov-plan.json" 2> "$WORK/checkov-plan.err" + fi + [ -s "$WORK/checkov-plan.json" ] || fail "checkov (plan) produced no output" + echo "" + echo "checkov (plan) summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov-plan.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" + echo "" + echo "=== Step 6: Checkov (plan) adapter -> PolicyCheckResult ===" + python3 adapters/terraform/policy/checkov_adapter.py "$WORK/checkov-plan.json" "$CONTRACT_ID" > "$WORK/pcr.json" || fail "checkov (plan) adapter failed" +fi +echo "runtime scan engine: $RUNTIME_SCAN_ENGINE" python3 -c " import json pcrs = json.load(open('$WORK/pcr.json')) @@ -494,5 +525,5 @@ source "$ROOT/scripts/run_uptime.sh" echo "" echo "=== PLATFORM E2E OK ===" -echo "contract -> resolver -> stack -> terraform plan -> Checkov -> confidence ($BAND) -> outbox -> outputs" +echo "contract -> resolver -> stack -> Checkov(static) -> terraform plan -> Wiz-or-Checkov(plan) -> confidence ($BAND) -> outbox -> outputs" exit 0 \ No newline at end of file diff --git a/scripts/run_postapply.sh b/scripts/run_postapply.sh index 121fc57..30f8360 100755 --- a/scripts/run_postapply.sh +++ b/scripts/run_postapply.sh @@ -2,8 +2,9 @@ # scripts/run_postapply.sh — post-Terraform steps for the Nova platform pipeline. # # Performs steps 5–9 of run_platform.sh (after terraform apply/destroy): -# 5. Checkov policy scan on the emitted main.tf -# 6. Checkov adapter → PolicyCheckResult (compliance details) +# 3c. Checkov policy scan on static code (fail-fast, in run_codegen.sh) +# 5. Runtime policy scan on the terraform plan (Wiz-or-Checkov, never both) +# 6. Policy scan adapter → PolicyCheckResult (compliance details) # 7. Confidence signal compute # 7b. HITL attestation gate (qa/prod/dr only) # 8. Write evidence event to DynamoDB outbox @@ -92,19 +93,37 @@ else: " || { echo "FAIL: HITL gate blocked" >&2; return 1; } } -echo "=== Step 5: run Checkov on $TF_DIR/main.tf ===" -if [ "$QUIET" = "0" ]; then - checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov.json" +echo "=== Step 5: runtime policy scan on the terraform plan (Wiz-or-Checkov, never both) ===" +# REQ-250 (v1.21): after terraform plan, run Wiz against the plan when +# configured; otherwise run Checkov against the plan as a drop-in +# replacement. Wiz and Checkov are NEVER both run on the plan. The +# static-code Checkov already ran in run_codegen.sh Step 3c (fail-fast). +RUNTIME_SCAN_ENGINE="" +if [ -n "${WIZ_API_TOKEN:-}" ] || [ -n "${WIZ_API_URL:-}" ]; then + RUNTIME_SCAN_ENGINE="wiz" + echo "--- Wiz configured (WIZ_API_TOKEN + WIZ_API_URL) → Wiz on the plan ---" + python3 adapters/wiz/wiz_adapter.py --plan "$TF_DIR/tfplan" --contract-id "$CONTRACT_ID" --run-id "${CONTRACT_ID}" > "$WORK/pcr.json" 2> "$WORK/wiz.err" || { + echo "WARNING: Wiz scan failed; falling back to Checkov on the plan" >&2 + RUNTIME_SCAN_ENGINE="checkov-plan" + } else - checkov -f "$TF_DIR/main.tf" --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov.json" 2> "$WORK/checkov.err" + RUNTIME_SCAN_ENGINE="checkov-plan" fi -[ -s "$WORK/checkov.json" ] || { echo "FAIL: checkov produced no output" >&2; exit 1; } -echo "" -echo "checkov summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" - -echo "" -echo "=== Step 6: Checkov adapter → PolicyCheckResult (compliance details) ===" -python3 adapters/terraform/policy/checkov_adapter.py "$WORK/checkov.json" "$CONTRACT_ID" > "$WORK/pcr.json" || { echo "FAIL: checkov adapter failed" >&2; exit 1; } +if [ "$RUNTIME_SCAN_ENGINE" = "checkov-plan" ]; then + echo "--- Wiz not configured → Checkov on the plan (drop-in replacement) ---" + if [ "$QUIET" = "0" ]; then + checkov -f "$TF_DIR/tfplan" --framework terraform_plan -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov-plan.json" + else + checkov -f "$TF_DIR/tfplan" --framework terraform_plan -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov-plan.json" 2> "$WORK/checkov-plan.err" + fi + [ -s "$WORK/checkov-plan.json" ] || { echo "FAIL: checkov (plan) produced no output" >&2; exit 1; } + echo "" + echo "checkov (plan) summary: $(python3 -c "import json; d=json.load(open('$WORK/checkov-plan.json')); print(len(d.get('results',{}).get('failed_checks',[])), 'failed,', len(d.get('results',{}).get('passed_checks',[])), 'passed')")" + echo "" + echo "=== Step 6: Checkov (plan) adapter → PolicyCheckResult (compliance details) ===" + python3 adapters/terraform/policy/checkov_adapter.py "$WORK/checkov-plan.json" "$CONTRACT_ID" > "$WORK/pcr.json" || { echo "FAIL: checkov (plan) adapter failed" >&2; exit 1; } +fi +echo "runtime scan engine: $RUNTIME_SCAN_ENGINE" python3 -c " import json pcrs = json.load(open('$WORK/pcr.json')) @@ -199,4 +218,4 @@ source "$ROOT/scripts/run_uptime.sh" echo "" echo "=== POST-APPLY OK ===" -echo "Checkov → confidence ($BAND) → outbox → outputs → uptime" +echo "Checkov(static, pre-plan) → Wiz-or-Checkov(plan) → confidence ($BAND) → outbox → outputs → uptime" diff --git a/tests/test_contract_resolver.py b/tests/test_contract_resolver.py index cf90773..5b189f3 100644 --- a/tests/test_contract_resolver.py +++ b/tests/test_contract_resolver.py @@ -173,16 +173,19 @@ class TestDeployPipelineContract: contract = yaml.safe_load(fh) jsonschema.validate(contract, schema) - def test_deploy_pipeline_has_six_stages(self): + def test_deploy_pipeline_has_required_stages(self): with open(ROOT / "pipelines/contract.yml") as fh: contract = yaml.safe_load(fh) stage_names = [s["name"] for s in contract["stages"]] assert "validate-contract" in stage_names assert "resolve-stack" in stage_names + assert "checkov-static" in stage_names, "REQ-250: checkov-static stage missing" assert "terraform-plan" in stage_names - assert "checkov" in stage_names + assert "runtime-policy-scan" in stage_names, "REQ-250: runtime-policy-scan stage missing" assert "confidence" in stage_names assert "apply" in stage_names + # The old single 'checkov' stage is gone (split into checkov-static + runtime-policy-scan) + assert "checkov" not in stage_names, "old 'checkov' stage should be replaced by checkov-static + runtime-policy-scan" class TestL2OutputsResolution: diff --git a/tests/test_pipeline_contract.py b/tests/test_pipeline_contract.py index 3f7004e..66e265e 100644 --- a/tests/test_pipeline_contract.py +++ b/tests/test_pipeline_contract.py @@ -206,14 +206,15 @@ class TestDeployPipelineContract: contract = _load_yaml("pipelines/contract.yml") jsonschema.validate(contract, schema) - def test_deploy_contract_has_nine_stages(self): + def test_deploy_contract_has_ten_stages(self): contract = _load_yaml("pipelines/contract.yml") stage_names = [s["name"] for s in contract["stages"]] assert stage_names == [ "validate-contract", "resolve-stack", + "checkov-static", "terraform-plan", - "checkov", + "runtime-policy-scan", "confidence", "apply", "publish-outputs",