Files
CASAN/packages/casan-harness/scripts/bash/security-check.sh
T
2026-07-19 12:14:12 +07:00

442 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# CASAN H4 Security Harness
# Usage:
# security-check.sh <input-file> <output-file> [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-file> <output-file> [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"
# Prefer the OS Python over framework/shim installations that may exist in a
# developer shell but cannot execute. The runtime image also exposes this path.
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in /usr/bin/python3 python3 python; do
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
if [[ -z "$PYTHON_BIN" ]]; then
echo "SECURITY_RUNTIME_UNAVAILABLE: working Python 3 interpreter not found" >&2
exit 69
fi
# 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
echo "SECURITY_BLOCKED: input file not found: $INPUT_FILE" >&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_BIN" -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_BIN" - "$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
}
# Load only rule patterns whose declared action matches the requested action.
# The previous generic loader returned every `pattern:` in prompt-filter.yaml,
# which accidentally promoted `require_approval`, `alert`, and `log` rules to
# hard blocks. That made ordinary source code containing methods such as
# `delete()` fail the workspace-context scan as prompt injection.
load_yaml_rule_patterns() {
local file="$1"
local requested_action="$2"
[[ -f "$file" ]] || return 0
"$PYTHON_BIN" - "$file" "$requested_action" <<'PY'
import re
import sys
path, requested_action = sys.argv[1], sys.argv[2]
pattern = None
action = None
def flush():
if pattern is not None and action == requested_action:
print(pattern)
with open(path, encoding="utf-8") as handle:
for line in handle:
if re.match(r"^\s*-\s+id:\s*", line):
flush()
pattern = None
action = None
continue
pattern_match = re.match(r'^\s*pattern:\s*"(.*)"\s*$', line)
if pattern_match:
pattern = pattern_match.group(1)
continue
action_match = re.match(r"^\s*action:\s*([A-Za-z_]+)\s*$", line)
if action_match:
action = action_match.group(1)
flush()
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_rule_patterns "$SECURITY_DIR/prompt-filter.yaml" "block")
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,}'
# Keep the fallback aligned with pii-rules.yaml. The previous separator-heavy
# expression treated ISO dates such as 2026-07-19 as phone numbers and corrupted
# timestamps in certified evidence.
PHONE_REGEX='([+]?[0-9]{9,15})'
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 [[ -n "$PYTHON_BIN" ]]; then
UNI_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$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 [[ -n "$PYTHON_BIN" ]]; then
DECODED_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$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). An explicitly
# requested strict scan still fails closed when the classifier is down.
# Production's implicit strict mode uses a risk-based degraded verdict so
# a classifier outage cannot take down every read-only Ask CASAN request.
# * 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
STRICT_EXPLICIT=0
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then
STRICT_ON=1
STRICT_EXPLICIT=1
elif [[ -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_BIN" -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_EXPLICIT" == "1" || "${CASAN_SECURITY_UNAVAILABLE_POLICY:-risk_based}" == "block" ]]; 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
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
ACTION="alert"
MATCHED_RULES+=("semantic-unavailable")
if [[ "$STRICT_ON" == "1" ]]; then
casan_log warn security "SEMANTIC_DEGRADED trace_id=$TRACE_ID reason=model_unavailable action=keep_deterministic_verdict hint=set_CASAN_SECURITY_UNAVAILABLE_POLICY=block_to_fail_closed"
else
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
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" ]] && [[ -n "$PYTHON_BIN" ]]; then
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | "$PYTHON_BIN" "$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_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
CATEGORIES_CSV="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | "$PYTHON_BIN" -c 'import sys; rules=sys.stdin.read().splitlines(); cats=[]
for rule in rules:
cat = (rule.split(":", 1)[0] if ":" in rule else rule).replace("semantic-strict-unavailable", "semantic-availability").replace("semantic-unavailable", "semantic-availability")
if cat and cat not in cats: cats.append(cat)
print(",".join(cats))')"
CATEGORIES_JSON="$(printf '%s' "$CATEGORIES_CSV" | "$PYTHON_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().split(",") if x]))')"
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
cat > "$TRACE_FILE" <<EOF
{
"trace_id": "$TRACE_ID",
"timestamp": "$TIMESTAMP",
"harness": "H4-security",
"mode": "$MODE",
"status": "$STATUS",
"action": "$ACTION",
"risk_level": "$RISK_LEVEL",
"matched_rules": $RULES_JSON,
"matched_categories": $CATEGORIES_JSON,
"input_hash": "$INPUT_HASH",
"output_hash": "$OUTPUT_HASH"
}
EOF
printf '{"timestamp":"%s","trace_id":"%s","harness":"H4-security","mode":"%s","status":"%s","action":"%s","risk_level":"%s","matched_categories":%s,"input_hash":"%s","output_hash":"%s"}\n' \
"$TIMESTAMP" "$TRACE_ID" "$MODE" "$STATUS" "$ACTION" "$RISK_LEVEL" "$CATEGORIES_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$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 categories=$CATEGORIES_CSV output=$OUTPUT_FILE"