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:
thanhnv
2026-07-03 22:20:50 +09:00
co-authored by Claude Opus 4.8
parent fbcef967e5
commit ac40c0b281
5 changed files with 283 additions and 24 deletions
@@ -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