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:
co-authored by
Claude Opus 4.8
parent
8c06a55aed
commit
d695a598ee
@@ -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.
|
||||||
props = schema.get("properties", {})
|
def validate(schema, data, path):
|
||||||
for field in schema.get("required", []):
|
expected = schema.get("type")
|
||||||
if field not in data:
|
py = TYPES.get(expected)
|
||||||
errors.append(f"missing required field: {field}")
|
if py and (not isinstance(data, py)
|
||||||
if schema.get("additionalProperties") is False:
|
or (expected in ("integer", "number") and isinstance(data, bool))):
|
||||||
for key in data:
|
errors.append(f"{path or 'root'}: expected {expected}")
|
||||||
if key not in props:
|
return
|
||||||
errors.append(f"unexpected field: {key}")
|
if "enum" in schema and data not in schema["enum"]:
|
||||||
for key, spec in props.items():
|
errors.append(f"{path or 'root'}: not in enum {schema['enum']}")
|
||||||
if key not in data:
|
|
||||||
continue
|
if expected == "object" and isinstance(data, dict):
|
||||||
expected = spec.get("type")
|
props = schema.get("properties", {})
|
||||||
py = TYPES.get(expected)
|
for field in schema.get("required", []):
|
||||||
# bool is a subclass of int — guard so a boolean isn't accepted as integer
|
if field not in data:
|
||||||
if py and (not isinstance(data[key], py)
|
errors.append(f"{path or 'root'}: missing required field: {field}")
|
||||||
or (expected in ("integer", "number") and isinstance(data[key], bool))):
|
if schema.get("additionalProperties") is False:
|
||||||
errors.append(f"field {key}: expected {expected}")
|
for key in data:
|
||||||
if "enum" in spec and data[key] not in spec["enum"]:
|
if key not in props:
|
||||||
errors.append(f"field {key}: not in enum {spec['enum']}")
|
errors.append(f"{path or 'root'}: unexpected field: {key}")
|
||||||
|
for key, spec in props.items():
|
||||||
|
if key in data:
|
||||||
|
validate(spec, data[key], f"{path}.{key}" if path else key)
|
||||||
|
elif expected == "array" and isinstance(data, list):
|
||||||
|
item_spec = schema.get("items")
|
||||||
|
if isinstance(item_spec, dict):
|
||||||
|
for i, item in enumerate(data):
|
||||||
|
validate(item_spec, item, f"{path}[{i}]")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Binary file not shown.
Reference in New Issue
Block a user