diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh index 5305bc2..a93bae9 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-harness.sh @@ -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 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/decode-suspicious.py b/AINative_OKR_CASAN5/.specify/scripts/bash/decode-suspicious.py new file mode 100755 index 0000000..61e1969 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/decode-suspicious.py @@ -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)) diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh index 4a7f37f..fcc082e 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/security-check.sh @@ -24,6 +24,11 @@ TRACE_DIR="$LOG_DIR/trace" AUDIT_DIR="$LOG_DIR/audit" SECURITY_DIR="$PROJECT_ROOT/.specify/security" +# Shared log taxonomy (error/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 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/tool-output-scan.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/tool-output-scan.sh new file mode 100755 index 0000000..ece22c9 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/tool-output-scan.sh @@ -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 [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 [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 diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/unicode-normalize.py b/AINative_OKR_CASAN5/.specify/scripts/bash/unicode-normalize.py new file mode 100755 index 0000000..b419bec --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/unicode-normalize.py @@ -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()))