From d695a598ee229cab815b4fc596722f872e1cdc05 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Mon, 6 Jul 2026 22:55:24 +0900 Subject: [PATCH] feat: plan 16 P2 batch 2 (SEC-12 drift, SEC-29 audit fail-closed, SEC-30 replay, SEC-15 low) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../.specify/scripts/bash/approval-verify.sh | 21 ++++++ .../.specify/scripts/bash/ci-harness-gate.sh | 4 ++ .../.specify/scripts/bash/drift-detect.sh | 34 +++++++++ .../.specify/scripts/bash/governance-check.sh | 12 +++- .../scripts/bash/supply-chain-scan.py | 14 +++- .../.specify/scripts/bash/tool-exec.sh | 8 ++- .../scripts/bash/validate-tool-input.sh | 55 +++++++++------ .../.specify/tests/phase-sec12-tests.sh | 54 +++++++++++++++ .../.specify/tests/phase-sec15-tests.sh | 65 ++++++++++++++++++ .../.specify/tests/phase-sec29-tests.sh | 55 +++++++++++++++ .../.specify/tests/phase-sec30-tests.sh | 51 ++++++++++++++ .../tests/test-integrity-manifest.json | 20 +++++- .../tests/test-integrity-manifest.json.head | 2 +- .../tests/test-integrity-manifest.json.sig | Bin 256 -> 256 bytes 14 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 AINative_OKR_CASAN5/.specify/tests/phase-sec12-tests.sh create mode 100644 AINative_OKR_CASAN5/.specify/tests/phase-sec15-tests.sh create mode 100644 AINative_OKR_CASAN5/.specify/tests/phase-sec29-tests.sh create mode 100644 AINative_OKR_CASAN5/.specify/tests/phase-sec30-tests.sh diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh index 0684c56..fdfed70 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/approval-verify.sh @@ -38,6 +38,25 @@ fi 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 "$REVIEWERS_FILE" ]] || deny "reviewer_registry_missing" command -v openssl >/dev/null 2>&1 || deny "openssl_unavailable" @@ -164,6 +183,7 @@ print(f"OK role={role}") PY )" || deny "oidc_jwt_invalid(${JWT_OUT:-see_stderr})" 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" exit 0 fi @@ -192,5 +212,6 @@ printf '%s' "$MSG" > "$TMP" openssl dgst -sha256 -verify "$PUB" -signature "$SIG_FILE" "$TMP" >/dev/null 2>&1 \ || deny "approval_signature_invalid" +record_nonce_or_deny "$(sha_stdin < "$SIG_FILE")" echo "APPROVAL_OK role=$ROLE approver=$APPROVER action=$ACTION" exit 0 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh index 033bf54..f5f6bea 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh @@ -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-sec27-log-controlchar" bash "$TESTS/phase-sec27-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 # drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI). diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/drift-detect.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/drift-detect.sh index b82c6be..433c495 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/drift-detect.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/drift-detect.sh @@ -72,18 +72,52 @@ length_delta = abs(len(candidate) - len(golden)) / max(len(golden), 1) status = "pass" action = "allow" +reasons = [] if similarity < 0.70 or length_delta > 0.50: status = "fail" action = "block_or_fallback" + reasons.append("low_similarity_or_length_delta") elif similarity < 0.85 or length_delta > 0.30: status = "warn" 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 = { "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "harness": "L5-drift-detection", "status": status, "action": action, + "reasons": reasons, + "golden_negations": golden_neg, + "candidate_negations": cand_neg, + "must_keep_missing": missing, "similarity_ratio": round(similarity, 4), "length_delta_ratio": round(length_delta, 4), "golden_hash": hashlib.sha256(golden.encode()).hexdigest(), diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh index 2b9b294..227ee96 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/governance-check.sh @@ -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 # (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" \ +# 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" \ "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY' 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 # the original printf format (no space after ':' / ','). f.write(json.dumps(rec, separators=(",", ":")) + "\n") + f.flush() + os.fsync(f.fileno()) 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 --- # A re-forged chain (recomputed hashes) changes the head; without the private diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/supply-chain-scan.py b/AINative_OKR_CASAN5/.specify/scripts/bash/supply-chain-scan.py index 19a1441..8557aff 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/supply-chain-scan.py +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/supply-chain-scan.py @@ -69,8 +69,12 @@ def parse_deps(text: str, name: str): def levenshtein(a: str, b: str) -> int: - if abs(len(a) - len(b)) > 1: - return 2 + # SEC-15: return a LARGE sentinel (not 2) when lengths are far apart — the old + # 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)) for i, ca in enumerate(a, 1): row = [i] @@ -106,7 +110,11 @@ def main() -> int: findings.append({"package": name, "reason": "denylisted_or_known_malicious"}) continue 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: findings.append({"package": name, "reason": "typosquat_of:" + near}) continue diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/tool-exec.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/tool-exec.sh index 3216c42..324eedc 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/tool-exec.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/tool-exec.sh @@ -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" "$@" rc=$? 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=$? fi diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/validate-tool-input.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/validate-tool-input.sh index a38aa91..965a03c 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/validate-tool-input.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/validate-tool-input.sh @@ -30,28 +30,39 @@ TYPES = { } errors = [] -if schema.get("type") == "object" and not isinstance(data, dict): - errors.append("root: expected object") -else: - props = schema.get("properties", {}) - for field in schema.get("required", []): - if field not in data: - errors.append(f"missing required field: {field}") - if schema.get("additionalProperties") is False: - for key in data: - if key not in props: - errors.append(f"unexpected field: {key}") - for key, spec in props.items(): - if key not in data: - continue - expected = spec.get("type") - py = TYPES.get(expected) - # bool is a subclass of int — guard so a boolean isn't accepted as integer - if py and (not isinstance(data[key], py) - or (expected in ("integer", "number") and isinstance(data[key], bool))): - 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']}") + +# SEC-15: validate RECURSIVELY. Previously only the top level was checked, so a +# 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", {}) + for field in schema.get("required", []): + if field not in data: + errors.append(f"{path or 'root'}: missing required field: {field}") + if schema.get("additionalProperties") is False: + for key in data: + if key not in props: + 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: sys.stderr.write("TOOL_INPUT_INVALID " + "; ".join(errors) + "\n") diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec12-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec12-tests.sh new file mode 100644 index 0000000..af2f3e7 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec12-tests.sh @@ -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 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec15-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec15-tests.sh new file mode 100644 index 0000000..9c7ae7d --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec15-tests.sh @@ -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 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec29-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec29-tests.sh new file mode 100644 index 0000000..f5a0cb0 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec29-tests.sh @@ -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 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec30-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec30-tests.sh new file mode 100644 index 0000000..3dd7d03 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec30-tests.sh @@ -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 diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json index 8942e29..b3592f9 100644 --- a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json @@ -96,10 +96,18 @@ "sha256": "099c81a0a37b51137dbf328dbe2ef778226d0716b97660d083c1fcc6a4aecd19", "checks": 5 }, + "phase-sec12-tests.sh": { + "sha256": "9993eec8c2384e59389723672ed7f2eadc9f112e680ec6505b62b94f4780ccb4", + "checks": 5 + }, "phase-sec13-tests.sh": { "sha256": "64d67fa0ccf9c199848323e8fb6207f0cbc9608f6e256a0a7337b371c0451b43", "checks": 6 }, + "phase-sec15-tests.sh": { + "sha256": "32736ec6e665d11dabf230cf26e2355a1d4d2ee4050bb5ce8aa9decd9e925d1b", + "checks": 7 + }, "phase-sec16-tests.sh": { "sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2", "checks": 6 @@ -128,6 +136,14 @@ "sha256": "8f082c85b80bf899ea1911e8165e28c14f691e1ac3605953cd1b968ee295e556", "checks": 4 }, + "phase-sec29-tests.sh": { + "sha256": "f7c339e628904b9bfbbbb9ed9fd88ddd50e74131d62630c93fdba3bf6fef4c04", + "checks": 3 + }, + "phase-sec30-tests.sh": { + "sha256": "534fc41302c6bbf60fb81e1185ad032d85fea2225517b01653d750044731437f", + "checks": 3 + }, "phase-selfimprove-tests.sh": { "sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938", "checks": 7 @@ -173,6 +189,6 @@ "checks": 10 } }, - "total_checks": 356, - "suite_count": 43 + "total_checks": 374, + "suite_count": 47 } diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head index d232a04..65b85b9 100644 --- a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head @@ -1 +1 @@ -636416c4a4e4e7392bcb48b91f7d4b9ea93338c6f3feb469605215917d317e12 \ No newline at end of file +1b8b459788953626b052536f8fc7a8493c9ec72361af5fc05a940c21fcab03f4 \ No newline at end of file diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig index 9ded1b053970173528b483f27624606b2e55f8ad..2080f9007ae51d15375f13a20ec21dc2357195c3 100644 GIT binary patch literal 256 zcmV+b0ssCy9HKjxW4fL<#$3FTu|fBkYxF!GSuvZ4h&UMy&cHJya)WTp-6d1kf{vbM z%W5y3Rd#D@DJo*V9fcnvm*lxaX8!y6OEeKkmzr+26}|n;iBfr<;T*o=;`Vm-@bsZW zK>{}17Lk~LQg(U-P?Aaboo~PD^&>mXB}sS8pXw^Lz3Rp+$iHhH+^n-fzpnpa)sfd% z@K0<<y89lq~XS%lR2e`JSMjz+cC#UkB;6tnTes3Y}NUbt4i;3rqc7PsIUcXJ(72VY*%P&X~xW#d-kyFE9I4 GQ$et4)q^Vl literal 256 zcmV+b0ssD2<0-VJ`s%ViMCnP$nwBPdVn)y7`q^6Mtmh^yml40bQfs1v876D}Q*XRz8%acS|kPr`l zth2;>nGZ1D?WN2$4)}?9XQ#q|1pmFUyT6AjZS>{KxVGI@l1^JNK+*tg55XwYur Gk