#!/usr/bin/env bash set -euo pipefail # CASAN H4 Security Harness # Usage: # security-check.sh [input|output] # # input mode: blocks prompt injection / critical secrets, masks PII, writes safe prompt. # output mode: redacts PII/secrets from generated output, flags risky language, writes safe output. INPUT_FILE="${1:-}" OUTPUT_FILE="${2:-}" MODE="${3:-input}" if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then echo "Usage: security-check.sh [input|output]" >&2 exit 64 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" LOG_DIR="$CASAN_STATE_ROOT/logs" TRACE_DIR="$LOG_DIR/trace" AUDIT_DIR="$LOG_DIR/audit" SECURITY_DIR="$CASAN_HARNESS_ROOT/security" # Shared log taxonomy (error&2 exit 1 fi # SEC-09 (M-10): cap input size — the regex/normalize/decode passes are superlinear, # so an oversized input is a DoS vector. Fail CLOSED (block) rather than churn on it. CASAN_MAX_INPUT_BYTES="${CASAN_MAX_INPUT_BYTES:-2097152}" # 2 MiB default INPUT_BYTES="$(wc -c < "$INPUT_FILE" 2>/dev/null | tr -d ' ')" if [[ -n "$INPUT_BYTES" && "$INPUT_BYTES" -gt "$CASAN_MAX_INPUT_BYTES" ]]; then echo "SECURITY_BLOCKED: input exceeds cap ($INPUT_BYTES > $CASAN_MAX_INPUT_BYTES bytes)" >&2 exit 1 fi timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ" } new_trace_id() { if command -v uuidgen >/dev/null 2>&1; then uuidgen | tr '[:upper:]' '[:lower:]' else printf 'trace-%s-%s\n' "$(date +%s)" "$$" fi } json_escape() { python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g' } hash_text() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}' else shasum -a 256 | awk '{print $1}' fi } # Normalize text to defeat trivial injection bypasses: # lowercase, fold common leetspeak to letters, collapse punctuation/whitespace. # Used ONLY for injection/jailbreak phrase matching, never for PII/secret regexes. normalize_for_match() { printf '%s' "$1" \ | tr '[:upper:]' '[:lower:]' \ | tr '013457@$' 'oieastas' \ | tr -c 'a-z0-9' ' ' \ | tr -s ' ' } load_yaml_values() { local file="$1" local key="$2" [[ -f "$file" ]] || return 0 python - "$file" "$key" <<'PY' import re import sys path, key = sys.argv[1], sys.argv[2] pattern = re.compile(rf'^\s*{re.escape(key)}:\s*"([^"]+)"\s*$') with open(path, encoding="utf-8") as f: for line in f: match = pattern.search(line) if match: print(match.group(1)) PY } TRACE_ID="$(new_trace_id)" TIMESTAMP="$(timestamp)" CONTENT="$(cat "$INPUT_FILE")" STATUS="pass" ACTION="allow" RISK_LEVEL="low" MATCHED_RULES=() BLOCK_PATTERNS=( "ignore (all |any )?(previous|prior|above|the above|earlier) (instruction|instructions|rule|rules|prompt|prompts|guideline|guidelines)" "ignore system instruction" "disregard (all |any |the )?.*(rule|instruction|previous|prior|above|earlier|policy|guideline)" "forget (all |everything |the |your )?.*(instruction|rule|previous|prior|above)" "bypass (the )?(system|policy|policies|filter|guardrail|guardrails|rule|rules|safety|restriction|restrictions)" "override (the )?(policy|policies|rule|rules|system|instruction|instructions|guardrail|safety)" "you are now" "act as (an? )?(admin|root|developer|unrestricted|unfiltered|different|evil|dan)" "act without (any )?(restriction|restrictions|rule|rules|limit|limits|filter|guardrail)" "pretend (to be|you are|you're) (system|not|no longer|an unrestricted)" "do anything now" "enable (dan|developer mode|jailbreak)" "developer mode" "reveal (the )?(system|hidden|internal|original) (prompt|instruction|instructions)" "show (the )?(hidden|internal|system) (instruction|instructions|prompt)" ) while IFS= read -r pattern; do [[ -n "$pattern" ]] && BLOCK_PATTERNS+=("$pattern") done < <(load_yaml_values "$SECURITY_DIR/prompt-filter.yaml" "pattern") APPROVAL_PATTERNS=( "delete[[:space:]].*" "drop table" "shutdown system" "export secrets" "dump database" ) ALERT_PATTERNS=( "show all data" "internal prompt" "system message" "hidden instruction" ) EMAIL_REGEX='[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' PHONE_REGEX='(\+?[0-9][0-9 .-]{8,}[0-9])' PERSONAL_ID_REGEX='\b[0-9]{9,12}\b' CREDIT_CARD_REGEX='\b([0-9]{4}[- ]?){3}[0-9]{4}\b' SECRET_REGEX='(API[_-]?KEY|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|PASSWORD|JWT[_-]?SECRET|SECRET)[[:space:]]*[:=][[:space:]]*[^[:space:]]+' PRIVATE_KEY_REGEX='-----BEGIN (RSA |EC )?PRIVATE KEY-----' DB_CONN_REGEX='(postgres|mysql|mongodb)://[^@[:space:]]+@' AWS_KEY_REGEX='AKIA[0-9A-Z]{16}' while IFS= read -r regex; do case "$regex" in *API*|*TOKEN*|*PASSWORD*|*SECRET*) regex="${regex//\\s/[[:space:]]}" regex="${regex//\\S/[^[:space:]]}" SECRET_REGEX="$regex" ;; esac done < <(load_yaml_values "$SECURITY_DIR/output-policy.yaml" "regex") lower_content="$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')" NORM_CONTENT="$(normalize_for_match "$CONTENT")" # 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' "$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 for pattern in "${BLOCK_PATTERNS[@]}"; do if match_either "$pattern"; then STATUS="blocked" ACTION="block" RISK_LEVEL="high" MATCHED_RULES+=("prompt-injection:$pattern") fi done if secret_match "$CREDIT_CARD_REGEX"; then STATUS="blocked" ACTION="block" RISK_LEVEL="high" MATCHED_RULES+=("pii-credit-card") fi 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" MATCHED_RULES+=("secret-in-input") fi if [[ "$STATUS" != "blocked" ]]; then for pattern in "${APPROVAL_PATTERNS[@]}"; do if match_either "$pattern"; then STATUS="requires_approval" ACTION="require_approval" RISK_LEVEL="high" MATCHED_RULES+=("unsafe-action:$pattern") fi done fi if [[ "$STATUS" != "blocked" ]]; then for pattern in "${ALERT_PATTERNS[@]}"; do if match_either "$pattern"; then [[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium" ACTION="alert" MATCHED_RULES+=("suspicious:$pattern") fi done fi # 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). # SEC-17 (ARCH-03): strict is ON when explicitly set, OR unset under prod profile # (secure-by-default). An explicit CASAN_SECURITY_STRICT=0 (internal scans) wins. STRICT_ON=0 if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then STRICT_ON=1 fi SEMANTIC_REQUIRED=0 if [[ "$STRICT_ON" == "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 [[ "$STRICT_ON" == "1" ]]; then STATUS="blocked"; ACTION="block"; RISK_LEVEL="high" 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 fi fi fi SAFE_CONTENT="$CONTENT" # Policy-driven PII masking (source of truth: pii-rules.yaml). Built-in sed # masking below remains as defense-in-depth if the policy file is unavailable. if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && command -v python >/dev/null 2>&1; then SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | python "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")" fi SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$EMAIL_REGEX/***MASKED_EMAIL***/g")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PHONE_REGEX/***MASKED_PHONE***/g")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PERSONAL_ID_REGEX/***MASKED_ID***/g")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$SECRET_REGEX/[REDACTED_SECRET]/Ig")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PRIVATE_KEY_REGEX/[REDACTED_PRIVATE_KEY]/Ig")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s#$DB_CONN_REGEX#[REDACTED_CONNSTRING]://#Ig")" SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$AWS_KEY_REGEX/[REDACTED_AWS_KEY]/Ig")" if [[ "$MODE" == "output" ]]; then 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 # output-policy.yaml level4_gate.fail_on: unredacted_secret -> fail closed. STATUS="blocked" ACTION="block" RISK_LEVEL="high" MATCHED_RULES+=("secret-in-output") fi if printf '%s' "$lower_content" | grep -Eq -- "(maybe|might be incorrect|i am not sure|uncertain)"; then ACTION="flag" [[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium" MATCHED_RULES+=("hallucination-risk-language") fi fi INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)" OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)" RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')" TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json" cat > "$TRACE_FILE" <> "$AUDIT_DIR/security.jsonl" if [[ "$STATUS" == "blocked" ]]; then : > "$OUTPUT_FILE" echo "SECURITY_BLOCKED trace_id=$TRACE_ID risk=$RISK_LEVEL rules=$RULES_JSON" >&2 exit 2 fi printf '%s\n' "$SAFE_CONTENT" > "$OUTPUT_FILE" STATUS_UPPER="$(printf '%s' "$STATUS" | tr '[:lower:]' '[:upper:]')" echo "SECURITY_${STATUS_UPPER} trace_id=$TRACE_ID risk=$RISK_LEVEL action=$ACTION output=$OUTPUT_FILE"