singularity-forge/scripts/base64-scan.sh
Mikael Hugo 8bbda93d24 chore: purge bun from internal toolchain
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>
2026-05-02 08:38:20 +02:00

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