feat: plan 16 P2 batch 2 (SEC-12 drift, SEC-29 audit fail-closed, SEC-30 replay, SEC-15 low)

- SEC-12: drift-detect adds semantic invariants — negation-flip detection (a dropped
  "not" now FAILS despite high char-similarity) + env must-keep patterns.
- SEC-29 (X-05): governance-check audit write fails CLOSED — an unwritable audit log
  denies the action and empties the output (no unaudited output).
- SEC-30 (X-06): approval-verify records a one-time-use nonce (sha of token/sig) and
  rejects replays (enforced mode / when a nonce ledger is set); dev unchanged.
- SEC-15 (low): typosquat distance<=2 with the levenshtein length-sentinel bug fixed
  (no false positives); tool-exec fails closed with no timeout backend in enforced
  mode; validate-tool-input now validates nested objects/arrays recursively.

Verify: new SEC suites all green via gate, run-casan4 0-FAIL, adversarial 44/44,
track-c 29/0, h5-approval 12/0, no regressions.

Plan-16 P2 remaining: infra-gated only (SEC-14/22/23/24/25/26).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-06 22:55:24 +09:00
co-authored by Claude Opus 4.8
parent 8c06a55aed
commit d695a598ee
14 changed files with 365 additions and 30 deletions
@@ -38,6 +38,25 @@ fi
deny() { echo "APPROVAL_DENIED reason=$1 approver=$APPROVER action=$ACTION" >&2; exit 3; } deny() { echo "APPROVAL_DENIED reason=$1 approver=$APPROVER action=$ACTION" >&2; exit 3; }
# SEC-30 (X-06): one-time-use. A verified approval token (JWT or offline signature)
# is valid for its whole exp window, so it could be REPLAYED. Record a per-token
# nonce (sha256 of the token/signature) and reject any repeat. Active in enforced
# mode or when an explicit nonce ledger is configured (dev default: off).
sha_stdin() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}'
else shasum -a 256 | awk '{print $1}'; fi
}
record_nonce_or_deny() {
local nonce="$1"
[[ "${CASAN_PROFILE:-}" == "prod" || -n "${CASAN_APPROVAL_NONCE_FILE:-}" ]] || return 0
local ledger="${CASAN_APPROVAL_NONCE_FILE:-$PROJECT_ROOT/.specify/logs/level5/approval-nonces.txt}"
mkdir -p "$(dirname "$ledger")" 2>/dev/null || true
if [[ -f "$ledger" ]] && grep -qxF "$nonce" "$ledger" 2>/dev/null; then
deny "approval_replayed(nonce=${nonce:0:12}…)"
fi
printf '%s\n' "$nonce" >> "$ledger" || deny "nonce_ledger_unwritable"
}
[[ -f "$INPUT_FILE" ]] || deny "input_file_missing" [[ -f "$INPUT_FILE" ]] || deny "input_file_missing"
[[ -f "$REVIEWERS_FILE" ]] || deny "reviewer_registry_missing" [[ -f "$REVIEWERS_FILE" ]] || deny "reviewer_registry_missing"
command -v openssl >/dev/null 2>&1 || deny "openssl_unavailable" command -v openssl >/dev/null 2>&1 || deny "openssl_unavailable"
@@ -164,6 +183,7 @@ print(f"OK role={role}")
PY PY
)" || deny "oidc_jwt_invalid(${JWT_OUT:-see_stderr})" )" || deny "oidc_jwt_invalid(${JWT_OUT:-see_stderr})"
ROLE="${JWT_OUT#OK role=}" ROLE="${JWT_OUT#OK role=}"
record_nonce_or_deny "$(printf '%s' "$CASAN_APPROVAL_JWT" | sha_stdin)"
echo "APPROVAL_OK mechanism=oidc role=$ROLE approver=$APPROVER action=$ACTION" echo "APPROVAL_OK mechanism=oidc role=$ROLE approver=$APPROVER action=$ACTION"
exit 0 exit 0
fi fi
@@ -192,5 +212,6 @@ printf '%s' "$MSG" > "$TMP"
openssl dgst -sha256 -verify "$PUB" -signature "$SIG_FILE" "$TMP" >/dev/null 2>&1 \ openssl dgst -sha256 -verify "$PUB" -signature "$SIG_FILE" "$TMP" >/dev/null 2>&1 \
|| deny "approval_signature_invalid" || deny "approval_signature_invalid"
record_nonce_or_deny "$(sha_stdin < "$SIG_FILE")"
echo "APPROVAL_OK role=$ROLE approver=$APPROVER action=$ACTION" echo "APPROVAL_OK role=$ROLE approver=$APPROVER action=$ACTION"
exit 0 exit 0
@@ -108,6 +108,10 @@ run "phase-sec10-agent-identity" bash "$TESTS/phase-sec10-tests.sh"
run "phase-sec13-ssrf" bash "$TESTS/phase-sec13-tests.sh" run "phase-sec13-ssrf" bash "$TESTS/phase-sec13-tests.sh"
run "phase-sec27-log-controlchar" bash "$TESTS/phase-sec27-tests.sh" run "phase-sec27-log-controlchar" bash "$TESTS/phase-sec27-tests.sh"
run "phase-sec28-path-traversal" bash "$TESTS/phase-sec28-tests.sh" run "phase-sec28-path-traversal" bash "$TESTS/phase-sec28-tests.sh"
run "phase-sec12-drift-invariant" bash "$TESTS/phase-sec12-tests.sh"
run "phase-sec29-audit-failclosed" bash "$TESTS/phase-sec29-tests.sh"
run "phase-sec30-approval-replay" bash "$TESTS/phase-sec30-tests.sh"
run "phase-sec15-low-cluster" bash "$TESTS/phase-sec15-tests.sh"
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently # 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). # drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
@@ -72,18 +72,52 @@ length_delta = abs(len(candidate) - len(golden)) / max(len(golden), 1)
status = "pass" status = "pass"
action = "allow" action = "allow"
reasons = []
if similarity < 0.70 or length_delta > 0.50: if similarity < 0.70 or length_delta > 0.50:
status = "fail" status = "fail"
action = "block_or_fallback" action = "block_or_fallback"
reasons.append("low_similarity_or_length_delta")
elif similarity < 0.85 or length_delta > 0.30: elif similarity < 0.85 or length_delta > 0.30:
status = "warn" status = "warn"
action = "require_review" action = "require_review"
# SEC-12: char-similarity alone misses SEMANTIC inversion — dropping a negation
# ("must NOT deploy" -> "must deploy") keeps similarity high but flips meaning. A
# candidate that removes negation tokens present in the golden is treated as drift.
import re as _re
NEG = _re.compile(
r"\b(not|no|never|cannot|can't|don't|must not|mustn't|deny|denied|reject|disable|"
r"disabled|forbid|prohibit|block|blocked|không|đừng|cấm|từ chối)\b",
_re.IGNORECASE,
)
golden_neg = len(NEG.findall(golden))
cand_neg = len(NEG.findall(candidate))
if golden_neg > cand_neg:
# The dangerous case: looks nearly identical but a negation vanished.
status, action = "fail", "block_or_fallback"
reasons.append(f"negation_dropped(golden={golden_neg},candidate={cand_neg})")
# must-keep invariants: regex patterns that MUST still appear in the candidate.
_mk = os.environ.get("CASAN_DRIFT_MUSTKEEP_FILE", "")
missing = []
if _mk and os.path.isfile(_mk):
for _line in open(_mk, encoding="utf-8", errors="replace"):
_pat = _line.strip()
if _pat and not _re.search(_pat, candidate):
missing.append(_pat)
if missing:
status, action = "fail", "block_or_fallback"
reasons.append(f"must_keep_missing={len(missing)}")
report = { report = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"harness": "L5-drift-detection", "harness": "L5-drift-detection",
"status": status, "status": status,
"action": action, "action": action,
"reasons": reasons,
"golden_negations": golden_neg,
"candidate_negations": cand_neg,
"must_keep_missing": missing,
"similarity_ratio": round(similarity, 4), "similarity_ratio": round(similarity, 4),
"length_delta_ratio": round(length_delta, 4), "length_delta_ratio": round(length_delta, 4),
"golden_hash": hashlib.sha256(golden.encode()).hexdigest(), "golden_hash": hashlib.sha256(golden.encode()).hexdigest(),
@@ -155,7 +155,10 @@ TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
# raw, so a value containing `"` + newline could inject a SECOND forged audit record # raw, so a value containing `"` + newline could inject a SECOND forged audit record
# (a fabricated "approved" decision). The record_hash is still computed from # (a fabricated "approved" decision). The record_hash is still computed from
# RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged. # RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged.
CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \ # SEC-29 (X-05): the audit write must FAIL CLOSED. If the audit log cannot be
# written (disk full, read-only, quota), there must be NO governed action without
# its accountability record — deny and empty the output rather than proceed.
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \ "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY' "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
import json, os, sys import json, os, sys
@@ -180,7 +183,14 @@ with open(audit_log, "a", encoding="utf-8") as f:
# Compact separators: the chain line is regex-parsed elsewhere and must match # Compact separators: the chain line is regex-parsed elsewhere and must match
# the original printf format (no space after ':' / ','). # the original printf format (no space after ':' / ',').
f.write(json.dumps(rec, separators=(",", ":")) + "\n") f.write(json.dumps(rec, separators=(",", ":")) + "\n")
f.flush()
os.fsync(f.fileno())
PY PY
then
: > "$OUTPUT_FILE" 2>/dev/null || true
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=audit_unwritable (fail-closed: no governed action without an audit record)" >&2
exit 2
fi
# --- External anchor: cryptographically sign the new chain head --- # --- External anchor: cryptographically sign the new chain head ---
# A re-forged chain (recomputed hashes) changes the head; without the private # A re-forged chain (recomputed hashes) changes the head; without the private
@@ -69,8 +69,12 @@ def parse_deps(text: str, name: str):
def levenshtein(a: str, b: str) -> int: def levenshtein(a: str, b: str) -> int:
if abs(len(a) - len(b)) > 1: # SEC-15: return a LARGE sentinel (not 2) when lengths are far apart — the old
return 2 # sentinel 2 collided with the widened distance<=2 typosquat threshold and
# produced false positives (e.g. fastapi vs numpy). For len-diff<=2 the DP below
# computes the true edit distance.
if abs(len(a) - len(b)) > 2:
return 99
prev = list(range(len(b) + 1)) prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1): for i, ca in enumerate(a, 1):
row = [i] row = [i]
@@ -106,7 +110,11 @@ def main() -> int:
findings.append({"package": name, "reason": "denylisted_or_known_malicious"}) findings.append({"package": name, "reason": "denylisted_or_known_malicious"})
continue continue
if name not in known: if name not in known:
near = next((k for k in known if levenshtein(name.lower(), k.lower()) == 1), None) # SEC-15: distance==1 missed 2-char typosquats (e.g. reqests/reqeusts of
# "requests"). Allow distance<=2 for names long enough that a 2-edit match
# is meaningful (short names stay at 1 to avoid false positives).
_maxd = 2 if len(name) >= 5 else 1
near = next((k for k in known if 1 <= levenshtein(name.lower(), k.lower()) <= _maxd), None)
if near: if near:
findings.append({"package": name, "reason": "typosquat_of:" + near}) findings.append({"package": name, "reason": "typosquat_of:" + near})
continue continue
@@ -29,7 +29,13 @@ elif command -v perl >/dev/null 2>&1; then
perl -e 'my $t=shift; $SIG{ALRM}=sub{exit 124}; alarm($t); exec @ARGV or exit 127;' "$TIMEOUT" "$@" perl -e 'my $t=shift; $SIG{ALRM}=sub{exit 124}; alarm($t); exec @ARGV or exit 127;' "$TIMEOUT" "$@"
rc=$? rc=$?
else else
echo "TOOL_EXEC_NO_TIMEOUT_BACKEND" >&2 # SEC-15: no timeout backend. In enforced mode REFUSE (fail-closed) rather than
# run a command that could hang the pipeline; dev warns and proceeds.
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_TOOL_EXEC_STRICT:-}" == "1" ]]; then
echo "TOOL_EXEC_NO_TIMEOUT_BACKEND refusing (fail-closed: install coreutils timeout or perl)" >&2
exit 2
fi
echo "TOOL_EXEC_NO_TIMEOUT_BACKEND (dev: running without timeout)" >&2
"$@" "$@"
rc=$? rc=$?
fi fi
@@ -30,28 +30,39 @@ TYPES = {
} }
errors = [] errors = []
if schema.get("type") == "object" and not isinstance(data, dict):
errors.append("root: expected object") # SEC-15: validate RECURSIVELY. Previously only the top level was checked, so a
else: # nested object could smuggle wrong types / unexpected fields past the gate.
def validate(schema, data, path):
expected = schema.get("type")
py = TYPES.get(expected)
if py and (not isinstance(data, py)
or (expected in ("integer", "number") and isinstance(data, bool))):
errors.append(f"{path or 'root'}: expected {expected}")
return
if "enum" in schema and data not in schema["enum"]:
errors.append(f"{path or 'root'}: not in enum {schema['enum']}")
if expected == "object" and isinstance(data, dict):
props = schema.get("properties", {}) props = schema.get("properties", {})
for field in schema.get("required", []): for field in schema.get("required", []):
if field not in data: if field not in data:
errors.append(f"missing required field: {field}") errors.append(f"{path or 'root'}: missing required field: {field}")
if schema.get("additionalProperties") is False: if schema.get("additionalProperties") is False:
for key in data: for key in data:
if key not in props: if key not in props:
errors.append(f"unexpected field: {key}") errors.append(f"{path or 'root'}: unexpected field: {key}")
for key, spec in props.items(): for key, spec in props.items():
if key not in data: if key in data:
continue validate(spec, data[key], f"{path}.{key}" if path else key)
expected = spec.get("type") elif expected == "array" and isinstance(data, list):
py = TYPES.get(expected) item_spec = schema.get("items")
# bool is a subclass of int — guard so a boolean isn't accepted as integer if isinstance(item_spec, dict):
if py and (not isinstance(data[key], py) for i, item in enumerate(data):
or (expected in ("integer", "number") and isinstance(data[key], bool))): validate(item_spec, item, f"{path}[{i}]")
errors.append(f"field {key}: expected {expected}")
if "enum" in spec and data[key] not in spec["enum"]:
errors.append(f"field {key}: not in enum {spec['enum']}") validate(schema, data, "")
if errors: if errors:
sys.stderr.write("TOOL_INPUT_INVALID " + "; ".join(errors) + "\n") sys.stderr.write("TOOL_INPUT_INVALID " + "; ".join(errors) + "\n")
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-12 — drift-detect semantic invariants (beyond char-similarity).
#
# Char-similarity alone misses a SEMANTIC inversion: dropping a negation
# ("must NOT deploy" -> "must deploy") keeps similarity ~0.97 but flips the meaning,
# and previously passed. Now a candidate that removes negations present in the
# golden, or that drops a must-keep invariant, FAILS. Proves the flip is caught and
# identical/benign content still passes.
#
# Deterministic; hermetic.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DD="$PROJECT_ROOT/.specify/scripts/bash/drift-detect.sh"
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-12: drift semantic invariants ====="
printf 'You MUST NOT deploy to production without explicit human approval.\n' > "$WORK/golden.txt"
printf 'You MUST deploy to production without explicit human approval.\n' > "$WORK/flip.txt"
cp "$WORK/golden.txt" "$WORK/same.txt"
# Negation flip: near-identical text (similarity ~0.97) but a "NOT" vanished.
[[ "$(rc_of bash "$DD" "$WORK/golden.txt" "$WORK/flip.txt" "$WORK/r1.json")" -ne 0 ]] \
&& pass "negation flip FAILS despite high similarity" \
|| fail "negation flip passed (semantic inversion missed)"
grep -q 'negation_dropped' "$WORK/r1.json" \
&& pass "report records the negation_dropped reason" || fail "reason not recorded"
# Identical golden/candidate still passes.
[[ "$(rc_of bash "$DD" "$WORK/golden.txt" "$WORK/same.txt" "$WORK/r2.json")" -eq 0 ]] \
&& pass "identical content passes (no false positive)" || fail "identical content flagged"
# must-keep invariant missing from candidate → FAIL.
printf 'MUST NOT deploy\n' > "$WORK/mustkeep.txt"
[[ "$(rc_of env CASAN_DRIFT_MUSTKEEP_FILE="$WORK/mustkeep.txt" bash "$DD" "$WORK/golden.txt" "$WORK/flip.txt" "$WORK/r3.json")" -ne 0 ]] \
&& pass "missing must-keep invariant FAILS" || fail "missing must-keep not detected"
# must-keep invariant present → pass.
printf 'production\n' > "$WORK/mustkeep2.txt"
[[ "$(rc_of env CASAN_DRIFT_MUSTKEEP_FILE="$WORK/mustkeep2.txt" bash "$DD" "$WORK/golden.txt" "$WORK/same.txt" "$WORK/r4.json")" -eq 0 ]] \
&& pass "present must-keep invariant passes" || fail "present must-keep wrongly failed"
echo ""
echo "===== SEC-12 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-15 — low-cluster hardening.
# * supply-chain typosquat: catch distance<=2 (was distance==1), no false positives
# (levenshtein length sentinel fixed so it no longer collides with the threshold),
# * tool-exec: fail CLOSED in enforced mode when no timeout backend exists,
# * validate-tool-input: recurse into nested objects/arrays (was one level deep).
#
# Deterministic; hermetic.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
S="$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-15: low-cluster hardening ====="
# ---- typosquat (distance<=2) ----
printf '{"dependencies":{}}' > "$WORK/base.json"
printf 'requests\nnumpy\nexpress\n' > "$WORK/known.txt"; : > "$WORK/deny.txt"
scan() { python3 "$S/supply-chain-scan.py" "$1" "$WORK/base.json" "$WORK/known.txt" "$WORK/deny.txt" "$WORK/r.json" "" >/dev/null 2>&1; }
printf '{"dependencies":{"reqeusts":"1.0.0"}}' > "$WORK/m1.json"; scan "$WORK/m1.json"
grep -q "typosquat_of:requests" "$WORK/r.json" \
&& pass "2-char typosquat (reqeusts→requests) flagged" || fail "2-char typosquat missed"
printf '{"dependencies":{"fastapi":"1.0.0"}}' > "$WORK/m2.json"; scan "$WORK/m2.json"
grep -q "typosquat" "$WORK/r.json" \
&& fail "legit package fastapi false-flagged as typosquat" \
|| pass "legit distant package not false-flagged (levenshtein sentinel fixed)"
# ---- tool-exec fail-closed when no timeout backend ----
# PATH=/bin has bash but not perl/timeout (both in /usr/bin) → no-backend branch.
[[ "$(set +e; PATH=/bin CASAN_TOOL_EXEC_STRICT=1 /bin/bash "$S/tool-exec.sh" 2 -- echo hi >/dev/null 2>&1; echo $?)" -eq 2 ]] \
&& pass "tool-exec refuses (fail-closed) with no timeout backend in enforced mode" \
|| fail "tool-exec did not fail closed without a timeout backend"
[[ "$(set +e; PATH=/bin /bin/bash "$S/tool-exec.sh" 2 -- echo hi >/dev/null 2>&1; echo $?)" -eq 0 ]] \
&& pass "tool-exec dev: runs without backend (backward compatible)" \
|| fail "tool-exec dev mode broke"
# ---- validate-tool-input recursion ----
cat > "$WORK/schema.json" <<'J'
{"type":"object","additionalProperties":false,"properties":{
"cfg":{"type":"object","additionalProperties":false,"properties":{"port":{"type":"integer"}}}}}
J
printf '{"cfg":{"port":8080}}' > "$WORK/ok.json"
printf '{"cfg":{"port":"NOPE"}}' > "$WORK/badtype.json"
printf '{"cfg":{"port":80,"evil":"x"}}' > "$WORK/badextra.json"
[[ "$(rc_of bash "$S/validate-tool-input.sh" "$WORK/schema.json" "$WORK/ok.json")" -eq 0 ]] \
&& pass "valid nested object accepted" || fail "valid nested object rejected"
[[ "$(rc_of bash "$S/validate-tool-input.sh" "$WORK/schema.json" "$WORK/badtype.json")" -eq 2 ]] \
&& pass "nested wrong type rejected (recursion)" || fail "nested wrong type slipped through"
[[ "$(rc_of bash "$S/validate-tool-input.sh" "$WORK/schema.json" "$WORK/badextra.json")" -eq 2 ]] \
&& pass "nested unexpected field rejected (recursion)" || fail "nested unexpected field slipped through"
echo ""
echo "===== SEC-15 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-29 (X-05) — audit write fails CLOSED.
#
# If the audit log cannot be written (disk full, read-only, quota), a governed
# action must NOT proceed — there is no action without its accountability record.
# Proves governance-check denies (and empties the output) when the audit log is
# unwritable, and still works normally otherwise.
#
# Restores the audit log it perturbs on exit.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GC="$PROJECT_ROOT/.specify/scripts/bash/governance-check.sh"
AUD="$PROJECT_ROOT/.specify/logs/audit/audit.jsonl"
WORK="$(mktemp -d)"
export CASAN_AUDIT_KEY_DIR="$WORK/keys"
restore() {
[[ -f "$AUD" ]] && chmod 644 "$AUD" 2>/dev/null || true
git -C "$PROJECT_ROOT" checkout -- .specify/logs/ .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-29: audit fail-closed when unwritable ====="
echo "benign objective text" > "$WORK/in.txt"
# Normal (writable) path works.
[[ "$(rc_of bash "$GC" "$WORK/in.txt" "$WORK/ok-out.txt" agent_step)" -eq 0 ]] \
&& pass "governed action succeeds when audit is writable" || fail "normal governed action failed"
# Make the audit log read-only so the append fails; the action must be denied.
echo "PREEXISTING_OUTPUT" > "$WORK/blocked-out.txt"
[[ -f "$AUD" ]] || echo '{}' > "$AUD"
chmod 444 "$AUD"
RC="$(rc_of bash "$GC" "$WORK/in.txt" "$WORK/blocked-out.txt" agent_step)"
chmod 644 "$AUD"
[[ "$RC" -ne 0 ]] \
&& pass "unwritable audit → governed action DENIED (fail-closed)" \
|| fail "action proceeded despite unwritable audit (fail-open)"
[[ ! -s "$WORK/blocked-out.txt" ]] \
&& pass "output emptied on audit failure (no unaudited output leaks)" \
|| fail "stale output left after audit failure"
echo ""
echo "===== SEC-29 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-30 (X-06) — approval replay prevention (one-time-use nonce).
#
# A verified approval (offline signature or IdP JWT) is valid for its whole exp
# window, so it could be replayed to approve repeatedly. approval-verify now records
# a per-token nonce and rejects any repeat (in enforced mode / when a nonce ledger
# is configured). Proves first use is accepted, replay is denied, and dev mode
# (no ledger) is unchanged.
#
# Self-contained: ephemeral reviewer key + committed registry.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
S="$PROJECT_ROOT/.specify/scripts/bash"
REG="$PROJECT_ROOT/.specify/level5/central-governance/reviewers.registry"
WORK="$(mktemp -d)"; RV="$WORK/reviewers"; mkdir -p "$RV"
trap 'rm -rf "$WORK"' EXIT
openssl genrsa -out "$WORK/sl.priv" 2048 2>/dev/null
openssl rsa -in "$WORK/sl.priv" -pubout -out "$RV/security-lead.pub.pem" 2>/dev/null
printf 'security.strict' > "$WORK/inp"
bash "$S/approval-sign.sh" policy_change alice "$WORK/inp" security-lead "$WORK/sl.priv" "$WORK/a.sig" >/dev/null 2>&1
export CASAN_REVIEWERS_FILE="$REG" CASAN_REVIEWERS_DIR="$RV"
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-30: approval replay prevention ====="
# With a nonce ledger configured, the first use is accepted, a replay is denied.
[[ "$(rc_of env CASAN_APPROVAL_NONCE_FILE="$WORK/nonces.txt" bash "$S/approval-verify.sh" policy_change alice "$WORK/inp" security-lead "$WORK/a.sig")" -eq 0 ]] \
&& pass "first use of a valid approval accepted" || fail "first use rejected"
[[ "$(rc_of env CASAN_APPROVAL_NONCE_FILE="$WORK/nonces.txt" bash "$S/approval-verify.sh" policy_change alice "$WORK/inp" security-lead "$WORK/a.sig")" -ne 0 ]] \
&& pass "replay of the same approval DENIED (one-time-use)" || fail "replay accepted"
# Dev default (no ledger, no prod profile): no replay tracking (backward compatible).
R1="$(rc_of bash "$S/approval-verify.sh" policy_change alice "$WORK/inp" security-lead "$WORK/a.sig")"
R2="$(rc_of bash "$S/approval-verify.sh" policy_change alice "$WORK/inp" security-lead "$WORK/a.sig")"
[[ "$R1" -eq 0 && "$R2" -eq 0 ]] \
&& pass "dev mode: no nonce ledger → replay allowed (backward compatible)" \
|| fail "dev mode changed (r1=$R1 r2=$R2)"
echo ""
echo "===== SEC-30 SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -96,10 +96,18 @@
"sha256": "099c81a0a37b51137dbf328dbe2ef778226d0716b97660d083c1fcc6a4aecd19", "sha256": "099c81a0a37b51137dbf328dbe2ef778226d0716b97660d083c1fcc6a4aecd19",
"checks": 5 "checks": 5
}, },
"phase-sec12-tests.sh": {
"sha256": "9993eec8c2384e59389723672ed7f2eadc9f112e680ec6505b62b94f4780ccb4",
"checks": 5
},
"phase-sec13-tests.sh": { "phase-sec13-tests.sh": {
"sha256": "64d67fa0ccf9c199848323e8fb6207f0cbc9608f6e256a0a7337b371c0451b43", "sha256": "64d67fa0ccf9c199848323e8fb6207f0cbc9608f6e256a0a7337b371c0451b43",
"checks": 6 "checks": 6
}, },
"phase-sec15-tests.sh": {
"sha256": "32736ec6e665d11dabf230cf26e2355a1d4d2ee4050bb5ce8aa9decd9e925d1b",
"checks": 7
},
"phase-sec16-tests.sh": { "phase-sec16-tests.sh": {
"sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2", "sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2",
"checks": 6 "checks": 6
@@ -128,6 +136,14 @@
"sha256": "8f082c85b80bf899ea1911e8165e28c14f691e1ac3605953cd1b968ee295e556", "sha256": "8f082c85b80bf899ea1911e8165e28c14f691e1ac3605953cd1b968ee295e556",
"checks": 4 "checks": 4
}, },
"phase-sec29-tests.sh": {
"sha256": "f7c339e628904b9bfbbbb9ed9fd88ddd50e74131d62630c93fdba3bf6fef4c04",
"checks": 3
},
"phase-sec30-tests.sh": {
"sha256": "534fc41302c6bbf60fb81e1185ad032d85fea2225517b01653d750044731437f",
"checks": 3
},
"phase-selfimprove-tests.sh": { "phase-selfimprove-tests.sh": {
"sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938", "sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938",
"checks": 7 "checks": 7
@@ -173,6 +189,6 @@
"checks": 10 "checks": 10
} }
}, },
"total_checks": 356, "total_checks": 374,
"suite_count": 43 "suite_count": 47
} }
@@ -1 +1 @@
636416c4a4e4e7392bcb48b91f7d4b9ea93338c6f3feb469605215917d317e12 1b8b459788953626b052536f8fc7a8493c9ec72361af5fc05a940c21fcab03f4