feat(h4-hardening): Plan-07 Track A — A1 strict semantic, A2 unicode/encoding, A3 tool-output scan
A1 (V1): CASAN_SECURITY_STRICT=1 makes semantic classification REQUIRED and fail-closed — model unavailable/no-verdict → BLOCK, never a silent SKIP. Non-strict CASAN_SEMANTIC_CLASSIFY=1 keeps regex verdict but logs SEMANTIC_SKIPPED loudly (sourced casan-log.sh). Default (no flags) unchanged. A2 (V3/V4): unicode-normalize.py (NFKC + zero-width strip + Cyrillic/Greek homoglyph fold) and decode-suspicious.py (base64/hex decode + rescan, printable filter to avoid false positives) feed new match_either/secret_match haystacks. Blocks homoglyph, zero-width, fullwidth, base64/hex-smuggled injection & secrets. A3 (V7): tool-output-scan.sh scans tool output for injection/secret before it re-enters model context; wrapper runs it after H6-exec (mode off|warn|block, strict→block). warn is default to preserve benign-draft behaviour. Baseline preserved: run-casan4 35/35, adversarial 44/44. 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
fbcef967e5
commit
ac40c0b281
@@ -24,6 +24,11 @@ TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
SECURITY_DIR="$PROJECT_ROOT/.specify/security"
|
||||
|
||||
# Shared log taxonomy (error<warn<info<debug<trace via CASAN_LOG_LEVEL). Used to
|
||||
# make semantic skips loud (never silent) — stderr only, stdout contract intact.
|
||||
# shellcheck source=casan-log.sh
|
||||
source "$SCRIPT_DIR/casan-log.sh"
|
||||
|
||||
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
@@ -150,13 +155,45 @@ done < <(load_yaml_values "$SECURITY_DIR/output-policy.yaml" "regex")
|
||||
lower_content="$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')"
|
||||
NORM_CONTENT="$(normalize_for_match "$CONTENT")"
|
||||
|
||||
# Matches a pattern against either the raw (case-insensitive) or the
|
||||
# normalization-folded content, so leetspeak/whitespace/punctuation
|
||||
# obfuscation cannot slip past a phrase blocklist.
|
||||
# Unicode-aware normalization (NFKC + zero-width strip + homoglyph fold) so
|
||||
# fullwidth/zero-width/Cyrillic-lookalike obfuscation cannot split or disguise
|
||||
# a blocked phrase (V3). Falls back to the raw content if python is missing.
|
||||
UNI_CONTENT="$CONTENT"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
UNI_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
|
||||
[[ -n "$UNI_CONTENT" ]] || UNI_CONTENT="$CONTENT"
|
||||
fi
|
||||
UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
|
||||
|
||||
# Encoding smuggling (V4): decode embedded base64/hex blobs and expose the
|
||||
# decoded plaintext so the same block/secret patterns can be re-run on it.
|
||||
# Only mostly-printable decodes survive, so random base64-looking words never
|
||||
# create a false positive.
|
||||
DECODED_CONTENT=""
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
DECODED_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# Matches a pattern against the raw (case-insensitive), leetspeak-folded,
|
||||
# unicode-normalized, and decoded-payload views of the content, so leetspeak,
|
||||
# whitespace/punctuation, homoglyph, zero-width and base64/hex obfuscation
|
||||
# cannot slip a phrase past the blocklist.
|
||||
match_either() {
|
||||
local pattern="$1"
|
||||
printf '%s' "$CONTENT" | grep -Eiq -- "$pattern" \
|
||||
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern"
|
||||
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern" \
|
||||
|| printf '%s' "$UNI_CONTENT" | grep -Eiq -- "$pattern" \
|
||||
|| printf '%s' "$UNI_NORM_CONTENT" | grep -Eq -- "$pattern" \
|
||||
|| { [[ -n "$DECODED_CONTENT" ]] && printf '%s' "$DECODED_CONTENT" | grep -Eiq -- "$pattern"; }
|
||||
}
|
||||
|
||||
# Secret/PII regex match against the raw content OR any decoded base64/hex
|
||||
# payload, so a secret smuggled through encoding is still caught (V4). Additive
|
||||
# only: with no decoded payload this is identical to the previous raw check.
|
||||
secret_match() {
|
||||
local regex="$1"
|
||||
printf '%s' "$CONTENT" | grep -Eiq -- "$regex" \
|
||||
|| { [[ -n "$DECODED_CONTENT" ]] && printf '%s' "$DECODED_CONTENT" | grep -Eiq -- "$regex"; }
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "input" ]]; then
|
||||
@@ -169,17 +206,17 @@ if [[ "$MODE" == "input" ]]; then
|
||||
fi
|
||||
done
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eq -- "$CREDIT_CARD_REGEX"; then
|
||||
if secret_match "$CREDIT_CARD_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("pii-credit-card")
|
||||
fi
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eiq -- "$SECRET_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$PRIVATE_KEY_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$DB_CONN_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$AWS_KEY_REGEX"; then
|
||||
if secret_match "$SECRET_REGEX" \
|
||||
|| secret_match "$PRIVATE_KEY_REGEX" \
|
||||
|| secret_match "$DB_CONN_REGEX" \
|
||||
|| secret_match "$AWS_KEY_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
@@ -207,23 +244,41 @@ if [[ "$MODE" == "input" ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Optional semantic escalation (opt-in: CASAN_SEMANTIC_CLASSIFY=1). The regex
|
||||
# layer above catches known phrasings; a genuinely novel paraphrase slips
|
||||
# through as low-risk. When enabled, route still-allowed input to the model
|
||||
# classifier. It can only ADD a block, never remove one. If the model backend
|
||||
# is unreachable, record it and keep the regex verdict (no silent pass of a
|
||||
# blocked item; no hard pipeline failure on infra outage).
|
||||
if [[ "$STATUS" != "blocked" && "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" && -x "$SCRIPT_DIR/model-router.sh" ]]; then
|
||||
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
|
||||
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
|
||||
if [[ -f "$SEM_JSON" ]]; then
|
||||
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
|
||||
# Semantic escalation. The regex layer above catches known phrasings; a
|
||||
# genuinely novel paraphrase slips through as low-risk, so a still-allowed
|
||||
# input is routed to the model classifier. Semantic can only ADD a block,
|
||||
# never remove one. Two modes:
|
||||
# * CASAN_SECURITY_STRICT=1 — semantic is REQUIRED (V1). If the model is
|
||||
# unreachable or returns no usable verdict we FAIL CLOSED (block); never a
|
||||
# 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).
|
||||
SEMANTIC_REQUIRED=0
|
||||
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then
|
||||
SEMANTIC_REQUIRED=1
|
||||
fi
|
||||
if [[ "$STATUS" != "blocked" && "$SEMANTIC_REQUIRED" == "1" ]]; then
|
||||
SEM_VERDICT=""
|
||||
if [[ -x "$SCRIPT_DIR/model-router.sh" ]]; then
|
||||
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
|
||||
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
|
||||
if [[ -f "$SEM_JSON" ]]; then
|
||||
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
fi
|
||||
fi
|
||||
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
|
||||
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("semantic-injection")
|
||||
elif [[ -z "$SEM_VERDICT" ]]; then
|
||||
# Model unreachable / no usable verdict.
|
||||
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then
|
||||
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("semantic-injection")
|
||||
MATCHED_RULES+=("semantic-strict-unavailable")
|
||||
casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block"
|
||||
else
|
||||
MATCHED_RULES+=("semantic-unavailable")
|
||||
casan_log warn security "SEMANTIC_SKIPPED trace_id=$TRACE_ID reason=model_unavailable action=keep_regex_verdict hint=set_CASAN_SECURITY_STRICT=1_to_fail_closed"
|
||||
fi
|
||||
else
|
||||
MATCHED_RULES+=("semantic-unavailable")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user