Node 24 is the only runtime — drop bun from nix-build skill instructions (use `npm run --workspace=...`) and from lockfile-skip globs in the secret/ base64 scanners. flake.nix dev shell already lost bun in the prior snapshot commit. End-user-facing package-manager.ts still supports bun by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
8.6 KiB
Bash
Executable file
242 lines
8.6 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Base64 obfuscation scanner — extracts base64 blobs from changed files,
|
|
# decodes them, and checks decoded content for prompt injection patterns.
|
|
#
|
|
# Catches obfuscated directives that would bypass docs-prompt-injection-scan.sh,
|
|
# which only scans raw text in markdown files.
|
|
#
|
|
# Usage:
|
|
# scripts/base64-scan.sh # scan staged files (pre-commit mode)
|
|
# scripts/base64-scan.sh --diff origin/main # scan diff vs branch (CI mode)
|
|
# scripts/base64-scan.sh --file path # scan a specific file
|
|
#
|
|
# Works on macOS (BSD grep) and Linux (GNU grep) — uses only ERE patterns.
|
|
|
|
set -euo pipefail
|
|
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m'
|
|
|
|
IGNOREFILE=".base64scanignore"
|
|
EXIT_CODE=0
|
|
FINDINGS=0
|
|
|
|
# Blobs shorter than this have too many false positives.
|
|
# 40 base64 chars decodes to ~30 bytes — minimum length for a meaningful directive.
|
|
MIN_BLOB_LEN=40
|
|
|
|
# ── Prompt injection patterns to match against decoded content ────────
|
|
# Format: "Label:::flags:::regex"
|
|
# Mirrors the patterns in docs-prompt-injection-scan.sh but applied to
|
|
# base64-decoded content across all file types.
|
|
DECODED_PATTERNS=(
|
|
# System prompt markers
|
|
"System prompt marker:::i:::<system-prompt>"
|
|
"System prompt marker:::i:::<\|im_start\|>system"
|
|
"System prompt marker:::i:::\[SYSTEM\][[:space:]]*:"
|
|
|
|
# Role injection / override
|
|
"Role injection:::i:::you are now [a-z]"
|
|
"Instruction override:::i:::ignore (all )?previous instructions"
|
|
"Instruction override:::i:::ignore (all )?prior instructions"
|
|
"Instruction override:::i:::disregard (all )?(above|previous|prior)"
|
|
"Instruction override:::i:::forget (all )?(above|previous|prior) (instructions|context|rules)"
|
|
"Instruction override:::i:::new instructions:"
|
|
"Instruction override:::i:::override (all )?instructions"
|
|
"Instruction override:::i:::your new role is"
|
|
"Instruction override:::i:::from now on,? (you (are|will|must|should)|act as)"
|
|
|
|
# Hidden HTML directives
|
|
"Hidden directive::::::<!--[[:space:]]*(PROMPT|INSTRUCTION|SYSTEM|OVERRIDE|INJECT)[[:space:]]*:"
|
|
"Hidden directive::::::<!--[[:space:]]*(ignore|disregard|forget|override)"
|
|
|
|
# Tool / function call injection
|
|
"Tool call injection::::::(<tool_call>|<function_call>|<tool_use>)"
|
|
"Tool call injection::::::(<invoke|<function_calls>)"
|
|
|
|
# Nested encode/eval attempts
|
|
"Nested encoding:::i:::eval\(|exec\(|Function\("
|
|
)
|
|
|
|
# ── Ignore-file support ───────────────────────────────────────────────
|
|
load_ignore_patterns() {
|
|
local ignore_patterns=()
|
|
if [[ -f "$IGNOREFILE" ]]; then
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
|
ignore_patterns+=("$line")
|
|
done < "$IGNOREFILE"
|
|
fi
|
|
echo "${ignore_patterns[@]+"${ignore_patterns[@]}"}"
|
|
}
|
|
|
|
is_ignored() {
|
|
local file="$1" blob="$2"
|
|
local ignore_patterns
|
|
read -ra ignore_patterns <<< "$(load_ignore_patterns)"
|
|
for pattern in "${ignore_patterns[@]+"${ignore_patterns[@]}"}"; do
|
|
if [[ "$pattern" == *:* ]]; then
|
|
local ignore_file="${pattern%%:*}"
|
|
local ignore_regex="${pattern#*:}"
|
|
if [[ "$file" == $ignore_file ]] && echo "$blob" | grep -qiE "$ignore_regex" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
else
|
|
if echo "$blob" | grep -qiE "$pattern" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── File filtering ────────────────────────────────────────────────────
|
|
# Scans all text file types — encoded instructions can hide anywhere.
|
|
should_scan() {
|
|
local file="$1"
|
|
# Skip binary formats
|
|
case "$file" in
|
|
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.woff|*.woff2|*.ttf|*.eot|\
|
|
*.zip|*.tar|*.gz|*.tgz|*.bz2|*.7z|*.rar|\
|
|
*.exe|*.dll|*.so|*.dylib|*.o|*.a|\
|
|
*.pdf|*.doc|*.docx|*.xls|*.xlsx|\
|
|
*.lock|package-lock.json|pnpm-lock.yaml|\
|
|
*.min.js|*.min.css|*.map|\
|
|
*.node|*.wasm)
|
|
return 1 ;;
|
|
esac
|
|
# Skip ignore/meta files
|
|
case "$file" in
|
|
.base64scanignore|.secretscanignore|.gitignore|.gitattributes|LICENSE*|CHANGELOG*)
|
|
return 1 ;;
|
|
esac
|
|
# Skip generated/vendor dirs
|
|
case "$file" in
|
|
node_modules/*|dist/*|coverage/*|.sf/*)
|
|
return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# ── File list and content ─────────────────────────────────────────────
|
|
get_files() {
|
|
if [[ "${1:-}" == "--diff" ]]; then
|
|
local ref="${2:-HEAD}"
|
|
git diff --name-only --diff-filter=ACMR "$ref" 2>/dev/null || true
|
|
elif [[ "${1:-}" == "--file" ]]; then
|
|
echo "${2:-}"
|
|
else
|
|
git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
get_content() {
|
|
local file="$1"
|
|
if [[ "${SCAN_MODE:-staged}" == "staged" ]]; then
|
|
git show ":$file" 2>/dev/null || cat "$file" 2>/dev/null || true
|
|
else
|
|
cat "$file" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ── Decode and check a single blob ────────────────────────────────────
|
|
check_blob() {
|
|
local file="$1" blob="$2" line_num="$3"
|
|
|
|
# Try to decode; skip if not valid base64
|
|
decoded=$(printf '%s' "$blob" | base64 --decode 2>/dev/null) || return 0
|
|
|
|
# Skip binary output: strip printable chars + whitespace; if anything remains it's binary
|
|
remainder=$(printf '%s' "$decoded" | tr -d '[:print:][:space:]')
|
|
[[ -n "$remainder" ]] && return 0
|
|
|
|
# Skip trivially short decoded content
|
|
[[ ${#decoded} -lt 8 ]] && return 0
|
|
|
|
# Check decoded content against each injection pattern
|
|
for entry in "${DECODED_PATTERNS[@]}"; do
|
|
label="${entry%%:::*}"
|
|
rest="${entry#*:::}"
|
|
flags="${rest%%:::*}"
|
|
regex="${rest#*:::}"
|
|
|
|
grep_flags="-E"
|
|
[[ "$flags" == *i* ]] && grep_flags="-Ei"
|
|
|
|
if printf '%s' "$decoded" | grep -q $grep_flags "$regex" 2>/dev/null; then
|
|
if is_ignored "$file" "$blob"; then
|
|
continue
|
|
fi
|
|
|
|
echo -e "${RED}[BASE64 ENCODED DIRECTIVE]${NC} ${YELLOW}${label}${NC}"
|
|
echo -e " File: ${CYAN}${file}:${line_num}${NC}"
|
|
echo " Encoded: ${blob:0:60}..."
|
|
echo " Decoded: $(printf '%s' "$decoded" | head -c 120)..."
|
|
echo ""
|
|
FINDINGS=$((FINDINGS + 1))
|
|
EXIT_CODE=1
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ── Argument parsing ──────────────────────────────────────────────────
|
|
SCAN_MODE="staged"
|
|
FILES_ARG=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--diff) SCAN_MODE="diff"; FILES_ARG=("--diff" "${2:-HEAD}"); shift 2 ;;
|
|
--file) SCAN_MODE="file"; FILES_ARG=("--file" "$2"); shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
FILES=$(get_files "${FILES_ARG[@]+"${FILES_ARG[@]}"}")
|
|
|
|
if [[ -z "$FILES" ]]; then
|
|
echo "base64-scan: no files to scan"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Main scan ─────────────────────────────────────────────────────────
|
|
while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
should_scan "$file" || continue
|
|
|
|
content=$(get_content "$file")
|
|
[[ -z "$content" ]] && continue
|
|
|
|
line_num=0
|
|
while IFS= read -r line; do
|
|
line_num=$((line_num + 1))
|
|
|
|
# Skip data URI lines — legitimate image/font embedding
|
|
echo "$line" | grep -qE 'data:[a-z]+/[a-z+.-]+;base64,' && continue
|
|
|
|
# Extract base64 candidates from this line
|
|
blobs=$(printf '%s' "$line" | grep -oE "[A-Za-z0-9+/]{${MIN_BLOB_LEN},}={0,2}" 2>/dev/null || true)
|
|
[[ -z "$blobs" ]] && continue
|
|
|
|
while IFS= read -r blob; do
|
|
[[ -z "$blob" ]] && continue
|
|
check_blob "$file" "$blob" "$line_num"
|
|
done <<< "$blobs"
|
|
done <<< "$content"
|
|
|
|
done <<< "$FILES"
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────
|
|
if [[ $FINDINGS -gt 0 ]]; then
|
|
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
echo -e "${RED}Found $FINDINGS base64-encoded directive(s).${NC}"
|
|
echo -e "${RED}Encoded instructions are not permitted in source files.${NC}"
|
|
echo -e "${RED}Add exceptions to .base64scanignore if these are${NC}"
|
|
echo -e "${RED}false positives.${NC}"
|
|
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
else
|
|
echo "base64-scan: no encoded directives detected ✓"
|
|
fi
|
|
|
|
exit $EXIT_CODE
|