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
@@ -115,6 +115,32 @@ else
|
||||
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
||||
fi
|
||||
|
||||
# V7: tool output can carry indirect injection that would re-enter a downstream
|
||||
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
|
||||
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
|
||||
# warn keeps existing behaviour (logged, non-blocking) so benign drafts are not
|
||||
# broken; block enforces (fail-closed) for production/strict runs.
|
||||
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
|
||||
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
|
||||
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
|
||||
fi
|
||||
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
|
||||
TOS_RC=0
|
||||
"$SCRIPT_DIR/tool-output-scan.sh" "$RAW_OUTPUT" "$ACTION_NAME" >/dev/null 2>&1 || TOS_RC=$?
|
||||
record_phase "H4-tool-output" "$TOS_RC"
|
||||
if [[ "$TOS_RC" -eq 2 ]]; then
|
||||
if [[ "$TOOL_OUTPUT_SCAN_MODE" == "block" ]]; then
|
||||
casan_log error harness "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME"
|
||||
: > "$FINAL_OUTPUT"
|
||||
write_phase_report
|
||||
echo "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME" >&2
|
||||
exit 2
|
||||
else
|
||||
casan_log warn harness "TOOL_OUTPUT_INJECTION_SUSPECTED action=$ACTION_NAME mode=warn hint=set_CASAN_TOOL_OUTPUT_SCAN=block_or_CASAN_SECURITY_STRICT=1_to_enforce"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
run_phase "H4-out" "$SCRIPT_DIR/security-check.sh" "$RAW_OUTPUT" "$FINAL_OUTPUT" output
|
||||
|
||||
if [[ "$CACHE_STATUS" == "stored" ]]; then
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H4 — Suspicious base64/hex decoder (Track A, V4 encoding smuggling).
|
||||
|
||||
Reads text on stdin. Finds embedded base64 and hex blobs, decodes them, and
|
||||
prints the decoded plaintext (one blob per line) on stdout so the caller can
|
||||
re-run its injection/secret pattern scan on the *decoded* content.
|
||||
|
||||
Safety / no-false-positive design:
|
||||
* Only blobs >= MIN_LEN characters are considered (short words are ignored).
|
||||
* A decoded blob is emitted ONLY if it is mostly printable text. Random
|
||||
base64-looking words (e.g. "objectives", DER key bytes) decode to
|
||||
non-printable garbage and are dropped, so they can never trigger a match.
|
||||
* Output is advisory: the caller decides a decoded blob is malicious only if
|
||||
the decoded text itself matches a block/secret pattern.
|
||||
|
||||
Deterministic: same input always yields the same output.
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
import sys
|
||||
|
||||
MIN_LEN = 16
|
||||
PRINTABLE_RATIO = 0.8
|
||||
|
||||
B64_RE = re.compile(r"[A-Za-z0-9+/]{%d,}={0,2}" % MIN_LEN)
|
||||
HEX_RE = re.compile(r"\b[0-9a-fA-F]{%d,}\b" % MIN_LEN)
|
||||
|
||||
|
||||
def _mostly_printable(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
ok = sum(1 for c in text if c.isprintable() or c.isspace())
|
||||
return ok >= PRINTABLE_RATIO * len(text)
|
||||
|
||||
|
||||
def decode_blobs(data: str):
|
||||
out = []
|
||||
for m in B64_RE.findall(data):
|
||||
pad = m + "=" * ((4 - len(m) % 4) % 4)
|
||||
try:
|
||||
dec = base64.b64decode(pad, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
txt = dec.decode("utf-8", "ignore")
|
||||
if _mostly_printable(txt):
|
||||
out.append(txt)
|
||||
for m in HEX_RE.findall(data):
|
||||
if len(m) % 2 != 0:
|
||||
continue
|
||||
try:
|
||||
dec = bytes.fromhex(m)
|
||||
except ValueError:
|
||||
continue
|
||||
txt = dec.decode("utf-8", "ignore")
|
||||
if _mostly_printable(txt):
|
||||
out.append(txt)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
blobs = decode_blobs(sys.stdin.read())
|
||||
if blobs:
|
||||
sys.stdout.write("\n".join(blobs))
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN H4 — Tool-output indirect-injection scanner (Track A, V7).
|
||||
#
|
||||
# A tool (shell command, file read, web fetch, sub-agent) can return content
|
||||
# that is then fed back into a downstream model's context. If that content
|
||||
# carries a prompt injection, the model can be hijacked even though the ORIGINAL
|
||||
# user input was clean. This scans a tool-output file the same way an untrusted
|
||||
# artifact is scanned, BEFORE the output is allowed to re-enter model context.
|
||||
#
|
||||
# It reuses security-check.sh in `input` mode (block-pattern + unicode/encoding
|
||||
# normalization + secret detection) but forces the semantic/strict model path
|
||||
# OFF so the scan is deterministic and needs no model backend — this is a
|
||||
# pattern scan of machine output, not a user-intent classification.
|
||||
#
|
||||
# Usage:
|
||||
# tool-output-scan.sh <tool-output-file> [context-label]
|
||||
# Exit:
|
||||
# 0 — safe to reuse
|
||||
# 2 — injection / secret pattern detected (caller should reject/quarantine)
|
||||
# 64 — usage error (file missing)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUTPUT_FILE="${1:-}"
|
||||
LABEL="${2:-unknown-tool}"
|
||||
|
||||
if [[ -z "$OUTPUT_FILE" || ! -f "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: tool-output-scan.sh <tool-output-file> [context-label]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
||||
SCAN_OUT="$WORK/tool-output-scan.txt"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
# Deterministic pattern scan: semantic + strict explicitly disabled here so a
|
||||
# tool-output scan never depends on (or is blocked by) model availability.
|
||||
CASAN_SECURITY_STRICT=0 CASAN_SEMANTIC_CLASSIFY=0 \
|
||||
bash "$SCRIPT_DIR/security-check.sh" "$OUTPUT_FILE" "$SCAN_OUT" input >/dev/null 2>&1
|
||||
SC_RC=$?
|
||||
|
||||
if [[ "$SC_RC" -eq 2 ]]; then
|
||||
echo "TOOL_OUTPUT_SCAN_BLOCKED label=$LABEL file=$OUTPUT_FILE reason=injection_or_secret timestamp=$TIMESTAMP"
|
||||
exit 2
|
||||
elif [[ "$SC_RC" -ne 0 ]]; then
|
||||
echo "TOOL_OUTPUT_SCAN_ERROR label=$LABEL rc=$SC_RC" >&2
|
||||
exit 2 # fail closed on scan error
|
||||
fi
|
||||
|
||||
echo "TOOL_OUTPUT_SCAN_CLEAN label=$LABEL file=$OUTPUT_FILE timestamp=$TIMESTAMP"
|
||||
exit 0
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H4 — Unicode confusable / obfuscation normalizer (Track A, V3).
|
||||
|
||||
Reads text on stdin, writes a normalized variant on stdout used ONLY for
|
||||
injection/jailbreak phrase matching (never for PII/secret redaction, so we
|
||||
never widen what counts as a secret).
|
||||
|
||||
Deterministic transforms, in order:
|
||||
1. NFKC compatibility normalization — folds fullwidth (ignore) and
|
||||
other compatibility forms to their ASCII equivalents.
|
||||
2. Strip zero-width / BOM / soft-hyphen formatting characters that split a
|
||||
word so a blocklist never sees it (i·g·n·o·r·e).
|
||||
3. Fold a fixed table of common Cyrillic/Greek homoglyphs to their Latin
|
||||
lookalikes (іgnоrе -> ignore). NFKC does NOT do this — confusables are a
|
||||
separate Unicode concern — so the table is explicit and auditable.
|
||||
|
||||
The table is intentionally small and covers the lookalikes actually used in
|
||||
prompt-injection homoglyph attacks; extend it as new vectors appear.
|
||||
"""
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# Zero-width, BOM, and invisible formatting code points.
|
||||
ZERO_WIDTH = {
|
||||
0x200B, # zero-width space
|
||||
0x200C, # zero-width non-joiner
|
||||
0x200D, # zero-width joiner
|
||||
0x2060, # word joiner
|
||||
0xFEFF, # BOM / zero-width no-break space
|
||||
0x00AD, # soft hyphen
|
||||
0x180E, # Mongolian vowel separator
|
||||
0x2061, 0x2062, 0x2063, 0x2064, # invisible math operators
|
||||
}
|
||||
|
||||
# Common Cyrillic / Greek homoglyphs -> Latin lookalike. Lowercase and
|
||||
# uppercase both listed because matching is case-insensitive downstream but the
|
||||
# fold must run before case handling to be safe.
|
||||
CONFUSABLES = {
|
||||
# Cyrillic lowercase
|
||||
"а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x",
|
||||
"ѕ": "s", "і": "i", "ј": "j", "ԁ": "d", "һ": "h", "ӏ": "l", "п": "n",
|
||||
"г": "r", "т": "t", "к": "k", "м": "m", "в": "b",
|
||||
# Cyrillic uppercase
|
||||
"А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O",
|
||||
"Р": "P", "С": "C", "Т": "T", "У": "Y", "Х": "X", "Ѕ": "S", "І": "I",
|
||||
"Ј": "J",
|
||||
# Greek
|
||||
"α": "a", "ε": "e", "ο": "o", "ρ": "p", "τ": "t", "ν": "v", "κ": "k",
|
||||
"Α": "A", "Β": "B", "Ε": "E", "Ζ": "Z", "Η": "H", "Ι": "I", "Κ": "K",
|
||||
"Μ": "M", "Ν": "N", "Ο": "O", "Ρ": "P", "Τ": "T", "Υ": "Y", "Χ": "X",
|
||||
}
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = "".join(ch for ch in text if ord(ch) not in ZERO_WIDTH)
|
||||
text = "".join(CONFUSABLES.get(ch, ch) for ch in text)
|
||||
return text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.stdout.write(normalize(sys.stdin.read()))
|
||||
Reference in New Issue
Block a user