diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd08b9f..28263f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,12 @@ jobs: bash -n scripts/triage_scan.sh bash -n benchmark/run_benchmark.sh - - name: Shellcheck (advisory) + # ubuntu-latest ships shellcheck preinstalled — no apt-get needed. + - name: Shellcheck (errors block the build; warnings are shown) run: | - sudo apt-get update && sudo apt-get install -y shellcheck - shellcheck -S warning scripts/triage_scan.sh benchmark/run_benchmark.sh || true + shellcheck --severity=error scripts/triage_scan.sh benchmark/run_benchmark.sh + shellcheck --severity=warning scripts/triage_scan.sh benchmark/run_benchmark.sh \ + || echo "::warning::shellcheck reported non-error findings above" - name: Dogfood — scan this repo with its own scanner run: bash scripts/triage_scan.sh . diff --git a/README.md b/README.md index 2bb052a..f66ca3c 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ You point it at a repo, a skill, an MCP server, or a package, and it does an att ## How it works -The review has two layers. First, a read-only scanner does a fast pass over 13 categories of risk, from install hooks and obfuscated payloads to leaked secrets and hidden instructions. Then a five-persona adversarial read looks at what the scanner surfaced and reasons about intent, which is the part a keyword search can't do. +The review has two layers. First, a read-only scanner does a fast pass over 14 categories of risk, from install hooks and obfuscated payloads to leaked secrets and hidden instructions. Then a five-persona adversarial read looks at what the scanner surfaced and reasons about intent, which is the part a keyword search can't do. -The scanner never runs the code it reviews. It reads files and reports what it sees. +The scanner never runs the code it reviews — it only reads files. It also does no online research: the "search for current attack techniques" step happens in the full workflow, run by the agent or person, not by the shell script. Treat whatever that research pulls in as untrusted evidence, since web pages and repos can themselves carry prompt injection, not as instructions to act on. ## Why not just a signature scanner @@ -105,7 +105,7 @@ Reviewing code is not the same as running it. Clone into a sandbox, don't instal ``` trust-issues/ ├── SKILL.md the workflow: acquire safely → research attacks → scan → 5-persona read → verdict -├── scripts/triage_scan.sh read-only static triage, 13 categories +├── scripts/triage_scan.sh read-only static triage, 14 categories ├── references/ threat catalog + report template ├── benchmark/ labeled fixtures + recall harness ├── ARCHITECTURE.md how it's built and why diff --git a/SKILL.md b/SKILL.md index 3d3ea22..a379b8a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -78,12 +78,12 @@ do that? Note every gap between claimed purpose and actual capability. ```bash bash scripts/triage_scan.sh ``` -This is READ-ONLY (never executes the target). It surfaces candidates across 13 -categories: inventory, binaries/blobs, npm install hooks, curl|bash, dynamic -exec/eval, base64/obfuscation, credential harvesting, network egress, leaked secrets, -CI/CD risk, dependency manifests, agent/prompt-injection directives, and hidden -unicode. Treat every hit as a lead to investigate, and remember a clean result proves -nothing on its own. +This is READ-ONLY (never executes the target) and does no online research. It surfaces +candidates across 14 categories: inventory, binaries/blobs, npm install hooks, +curl|bash, dynamic exec/eval, base64/obfuscation, credential harvesting, network egress, +leaked secrets, CI/CD risk, dependency manifests, agent/prompt-injection directives, +hidden unicode, and compiled-malware/injection/mining indicators. Treat every hit as a +lead to investigate, and remember a clean result proves nothing on its own. ### 4. Manual adversarial read through five personas This is the core of the review — the scanner can't reason about intent, you can. @@ -152,6 +152,6 @@ for manual follow-up; and the explicit verdict. Concentrated over exhaustive — sharp one-page report beats a 200-item checklist. ## Resources -- `scripts/triage_scan.sh` — read-only static triage (13 categories). +- `scripts/triage_scan.sh` — read-only static triage (14 categories). - `references/threat-catalog.md` — full threat taxonomy for the five personas. - `references/report-template.md` — required report + verdict format. diff --git a/benchmark/run_benchmark.sh b/benchmark/run_benchmark.sh index c4b8c1e..6cab68d 100755 --- a/benchmark/run_benchmark.sh +++ b/benchmark/run_benchmark.sh @@ -1,99 +1,131 @@ #!/usr/bin/env bash -# run_benchmark.sh — measure what the triage scanner actually catches. +# run_benchmark.sh — measure what the triage scanner actually catches, and whether +# it catches it in the RIGHT category. # -# Honest by design. The headline number is RECALL on known-malicious samples: -# of the malicious techniques in fixtures/, how many does the grep-based triage -# flag? We also report how many BENIGN samples surface a flag (the manual-review -# workload — high recall means some benign code gets surfaced, and that's the -# point of a triage layer, not a bug), and we spotlight any malicious sample that -# slips through, because "the scanner said nothing" is never proof of safety. +# Each malicious fixture declares the scanner category (section number) that should +# flag it. A fixture counts as caught only if it is flagged in one of its expected +# categories — not merely because the scan emitted "some output". That means a +# scanner that still prints noise but silently loses the rule that mattered will +# fail here. 'MISS' marks a fixture engineered to evade pattern matching; it is +# expected to slip through, and reporting that is the point. +# +# We also report how many BENIGN fixtures surface a flag (the manual-review +# workload — the scanner is tuned for recall; the reasoning pass clears these). # # Nothing here executes a fixture. Each sample is copied into a temp dir and # scanned read-only. # -# Usage: bash benchmark/run_benchmark.sh +# Usage: bash benchmark/run_benchmark.sh [--check] +# --check CI mode: exit 1 if any non-evasive fixture is missed or mis-categorized. set -uo pipefail CHECK=0 -[[ "${1:-}" == "--check" ]] && CHECK=1 # CI mode: exit 1 if a NON-evasive malicious sample is missed +[[ "${1:-}" == "--check" ]] && CHECK=1 HERE="$(cd "$(dirname "$0")" && pwd)" SCAN="$HERE/../scripts/triage_scan.sh" MAL="$HERE/fixtures/malicious" BEN="$HERE/fixtures/benign" -flagged() { # returns 0 (flagged) if the scan surfaces any signal for this sample - local sample="$1" tmp out sig +# fixture -> expected scanner category numbers (any one is sufficient). +declare -A EXPECT=( + [base64_exec.py]="5 6" + [curl_pipe_sh.sh]="4" + [dynamic_eval.js]="5" + [env_exfil.py]="7" + [hidden_unicode_SKILL.md]="12 13" + [miner_tor_c2.py]="14" + [npm_postinstall]="3" + [prompt_injection_SKILL.md]="12" + [reverse_shell.sh]="8" + [ssh_key_theft.py]="7" + [evasive_obfuscated.py]="MISS" +) + +# sections_for SAMPLE: echo the space-separated category numbers whose section +# references the sample. Section 1 (inventory) is ignored because it lists paths. +sections_for(){ + local sample="$1" name tmp out + name="$(basename "$sample")" tmp="$(mktemp -d)" cp -R "$sample" "$tmp/" out="$(bash "$SCAN" "$tmp" 2>/dev/null)" - # look only at the signal sections (3..13), never the inventory/summary - sig="$(printf '%s\n' "$out" | sed -n '/== 3\./,/== SUMMARY/p')" rm -rf "$tmp" - # a "hit" is any line that references the scanned temp path (path-printing - # sections) — inventory is excluded by the slice above - printf '%s\n' "$sig" | grep -q "$tmp" + printf '%s\n' "$out" | awk -v n="$name" ' + /^== [0-9]+\./ { if (match($0, /[0-9]+/)) sec = substr($0, RSTART, RLENGTH) } + /^== 1\./ { sec = "" } + sec != "" && index($0, n) { print sec } + ' | sort -un | tr "\n" " " } echo "############################################" echo "# Trust Issues — benchmark #" echo "############################################" echo +printf "MALICIOUS FIXTURES (must be flagged in an expected category)\n" -printf "MALICIOUS FIXTURES (want: FLAGGED)\n" -printf " %-28s %s\n" "sample" "result" -mal_total=0 mal_hit=0 missed="" +mal_total=0 mal_ok=0 missed="" wrongcat="" for s in "$MAL"/*; do - mal_total=$((mal_total+1)) name="$(basename "$s")" - if flagged "$s"; then - printf " %-28s \033[32mFLAGGED\033[0m\n" "$name"; mal_hit=$((mal_hit+1)) + expect="${EXPECT[$name]:-}" + hits="$(sections_for "$s")" + if [[ "$expect" == "MISS" ]]; then + if [[ -n "${hits// /}" ]]; then + printf " %-28s evasive — unexpectedly caught in %s\n" "$name" "$hits" + else + printf " %-28s MISS (by design)\n" "$name" + fi + continue + fi + mal_total=$((mal_total + 1)) + ok=0 + read -ra exp_arr <<< "$expect" + for e in "${exp_arr[@]}"; do + case " $hits " in *" $e "*) ok=1 ;; esac + done + if (( ok )); then + mal_ok=$((mal_ok + 1)); printf " %-28s OK (category %s)\n" "$name" "$expect" + elif [[ -n "${hits// /}" ]]; then + wrongcat="$wrongcat $name"; printf " %-28s WRONG CATEGORY (hit %s, expected %s)\n" "$name" "$hits" "$expect" else - printf " %-28s \033[31mMISSED\033[0m\n" "$name"; missed="$missed $name" + missed="$missed $name"; printf " %-28s MISSED entirely\n" "$name" fi done echo -printf "BENIGN FIXTURES (flag = surfaced for manual review)\n" -printf " %-28s %s\n" "sample" "result" +printf "BENIGN FIXTURES (a flag = surfaced for manual review, not a failure)\n" ben_total=0 ben_flag=0 noisy="" for s in "$BEN"/*; do - ben_total=$((ben_total+1)) name="$(basename "$s")" - if flagged "$s"; then - printf " %-28s \033[33mSURFACED\033[0m\n" "$name"; ben_flag=$((ben_flag+1)); noisy="$noisy $name" + ben_total=$((ben_total + 1)) + if [[ -n "$(sections_for "$s" | tr -d ' ')" ]]; then + ben_flag=$((ben_flag + 1)); noisy="$noisy $name"; printf " %-28s surfaced\n" "$name" else printf " %-28s quiet\n" "$name" fi done -recall=$(( mal_total ? 100*mal_hit/mal_total : 0 )) echo echo "--------------------------------------------" echo "RESULTS" -echo " Malicious recall: $mal_hit / $mal_total (${recall}%)" -echo " Missed (evasion): ${missed:- none}" -echo " Benign surfaced: $ben_flag / $ben_total for manual review:${noisy:- none}" +echo " Correct-category recall: $mal_ok / $mal_total" +echo " Missed entirely: ${missed:- none}" +echo " Wrong category: ${wrongcat:- none}" +echo " Benign surfaced: $ben_flag / $ben_total for review:${noisy:- none}" echo "--------------------------------------------" cat <<'NOTE' -How to read these numbers: -- Recall below 100% is expected. The evasive fixture is built to defeat pattern - matching, the same way real scanner-evasion works, so missing it is a designed - outcome that argues for the manual five-persona read and for sandboxing. -- "Benign surfaced" is the manual-review workload rather than a failure. The scanner - is tuned for recall, and the reasoning pass clears the legitimate cases such as a - scheduler using spawnSync, a regex calling .exec(), or a config reading one env var. -- A clean scan on its own does not clear the code. +How to read this: +- "Correct-category recall" is stronger than "emitted some output": a fixture only + counts if flagged in the category matching its technique. +- "Wrong category" means the scanner noticed something but lost the specific rule — + treat that as a regression to fix, not a pass. +- The evasive fixture is expected to be missed; that is the argument for the manual + five-persona read and for sandboxing. A clean scan never clears the code. NOTE -# CI gate: fail only if a malicious sample that is NOT meant to be evasive was missed. if [[ "$CHECK" == "1" ]]; then - unexpected="" - for m in $missed; do - case "$m" in *evasive*) : ;; *) unexpected="$unexpected $m" ;; esac - done - if [[ -n "$unexpected" ]]; then - echo; echo "CI FAIL: unexpected miss(es):$unexpected" >&2 + if [[ -n "${missed// /}" || -n "${wrongcat// /}" ]]; then + echo; echo "CI FAIL: missed:${missed:- none} | wrong-category:${wrongcat:- none}" >&2 exit 1 fi - echo; echo "CI OK: no unexpected misses." + echo; echo "CI OK: every non-evasive fixture flagged in its expected category." fi diff --git a/scripts/triage_scan.sh b/scripts/triage_scan.sh index 9f10b98..89d88cb 100755 --- a/scripts/triage_scan.sh +++ b/scripts/triage_scan.sh @@ -1,136 +1,190 @@ #!/usr/bin/env bash # triage_scan.sh — READ-ONLY static triage for an untrusted repo or skill. # -# This script NEVER executes any code from the target. It only reads files with -# grep/find/file. It is a fast first pass whose job is to surface things a human -# (or the reviewing agent) must then read manually. A clean run is NOT proof of -# safety — it is the floor, not the ceiling. Always follow with the manual -# adversarial read described in SKILL.md. +# This script NEVER executes code from the target. It only reads files with +# grep / find / file / wc. It does not follow symlinks (grep -r, find -P), skips +# common vendor and generated directories, and skips very large files in the +# binary pass. It is a fast first pass whose job is to surface things a human (or +# the reviewing agent) must then read manually. A clean run is NOT proof of +# safety — it is the floor, not the ceiling. # -# Usage: bash triage_scan.sh +# It does NO online research. The "research current threats" step in SKILL.md is +# performed by the agent or human running the full workflow, not by this script. +# +# For a hostile or very large target, bound the wall-clock time yourself, e.g.: +# timeout 120 bash triage_scan.sh # -# Exit code is always 0 (this is triage, not a gate). The DECISION is made by the -# reviewing agent/human using this output plus the manual read. +# Usage: bash triage_scan.sh +# Exit: 0 = ran (triage is informational, not a gate); 2 = bad arguments. set -uo pipefail -TARGET="${1:-}" -if [[ -z "$TARGET" || ! -d "$TARGET" ]]; then - echo "usage: bash triage_scan.sh "; exit 2 + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + echo "usage: bash triage_scan.sh "; exit 0 +fi +if [[ -z "${1:-}" ]]; then + echo "usage: bash triage_scan.sh " >&2; exit 2 +fi +TARGET="$1" +if [[ ! -d "$TARGET" ]]; then + echo "error: not a directory: $TARGET" >&2; exit 2 +fi +# Canonicalize early: an absolute, symlink-resolved path can't be misread as an +# option (leading '-') or escape the tree via a symlinked target. +if ! TARGET="$(cd -- "$TARGET" 2>/dev/null && pwd -P)"; then + echo "error: cannot access target" >&2; exit 2 fi -TARGET="${TARGET%/}" -SRC_INC=(--include=*.py --include=*.js --include=*.mjs --include=*.cjs --include=*.ts - --include=*.tsx --include=*.jsx --include=*.sh --include=*.bash --include=*.zsh - --include=*.rb --include=*.go --include=*.rs --include=*.php --include=*.pl - --include=*.ps1 --include=*.psm1) -TXT_INC=(--include=*.md --include=*.mdx --include=*.markdown --include=*.txt - --include=*.json --include=*.yml --include=*.yaml --include=*.toml --include=*.env*) +MAX_BYTES=$((5 * 1024 * 1024)) # skip files larger than 5 MB in the binary pass +PRUNE=( -path '*/.git/*' -o -path '*/node_modules/*' -o -path '*/vendor/*' + -o -path '*/dist/*' -o -path '*/build/*' -o -path '*/.venv/*' + -o -path '*/venv/*' -o -path '*/.next/*' -o -path '*/target/*' ) +EXCLUDES=( --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=vendor + --exclude-dir=dist --exclude-dir=build --exclude-dir=.venv + --exclude-dir=venv --exclude-dir=.next --exclude-dir=target ) + +SRC_INC=( --include=*.py --include=*.js --include=*.mjs --include=*.cjs --include=*.ts + --include=*.tsx --include=*.jsx --include=*.sh --include=*.bash --include=*.zsh + --include=*.rb --include=*.go --include=*.rs --include=*.php --include=*.pl + --include=*.ps1 --include=*.psm1 ) +TXT_INC=( --include=*.md --include=*.mdx --include=*.markdown --include=*.txt + --include=*.json --include=*.yml --include=*.yaml --include=*.toml --include=*.env* ) hr(){ printf '\n== %s ==\n' "$1"; } -g(){ grep -RIn --binary-files=without-match "$@" "$TARGET" 2>/dev/null | grep -v '/.git/' ; } -# go(): clean value extraction for -oE (no path:line prefix, .git excluded), deduped by caller -go(){ grep -rhoIE --binary-files=without-match --exclude-dir=.git "$@" "$TARGET" 2>/dev/null ; } + +# g(): recursive content grep. -r (not -R) does not follow symlinks while +# recursing; -I skips binary files; vendor/generated dirs are excluded; the '--' +# guards a target whose name might begin with '-'. +g(){ grep -rInI "${EXCLUDES[@]}" "$@" -- "$TARGET" 2>/dev/null; } +# go(): value-only extraction (-o, no path prefix) for de-duplication by callers. +go(){ grep -rhoIE "${EXCLUDES[@]}" "$@" -- "$TARGET" 2>/dev/null; } + +# cap N: print up to N matching lines from stdin; if more exist, report the total. +cap(){ + local n="${1:-10}" buf total + buf="$(cat)" + if [[ -z "$buf" ]]; then echo " (none)"; return 0; fi + total="$(printf '%s\n' "$buf" | grep -c .)" + printf '%s\n' "$buf" | head -n "$n" + if (( total > n )); then echo " … showing $n of $total matches"; fi + return 0 +} + +# tfind: null-delimited find over the target, no symlink following, pruning the +# vendor/generated dirs. Extra predicates are passed as arguments. +tfind(){ find -P "$TARGET" '(' "${PRUNE[@]}" ')' -prune -o "$@" -print0 2>/dev/null; } + +HAVE_P=0; printf 'x\n' | grep -qP 'x' 2>/dev/null && HAVE_P=1 echo "######## TRIAGE: $TARGET ########" -echo "(read-only signature scan — manual review still required)" +echo "(read-only signature scan — manual review still required; this script does no online research)" hr "1. INVENTORY" -echo "Tracked files by extension:" -find "$TARGET" -type f -not -path '*/.git/*' 2>/dev/null \ - | sed 's/.*\.//; s#.*/##' | sort | uniq -c | sort -rn | head -30 +echo "File types present:" +tfind -type f | tr '\0' '\n' | sed 's/.*\.//; s#.*/##' | sort | uniq -c | sort -rn | head -30 echo; echo "Largest files (watch for vendored blobs / minified bundles):" -find "$TARGET" -type f -not -path '*/.git/*' -printf '%s\t%p\n' 2>/dev/null \ - | sort -rn | head -10 | awk '{printf " %10d %s\n",$1,$2}' +find -P "$TARGET" '(' "${PRUNE[@]}" ')' -prune -o -type f -exec wc -c {} + 2>/dev/null \ + | grep -vE '[[:space:]]total$' | sort -rn | head -10 \ + | awk '{n=$1; $1=""; sub(/^[[:space:]]+/,""); printf " %12d %s\n", n, $0}' hr "2. NON-TEXT / BINARY / HIGH-ENTROPY BLOBS (unexplained binaries are a red flag)" -find "$TARGET" -type f -not -path '*/.git/*' 2>/dev/null | while read -r f; do - case "$f" in *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff*|*.ttf|*.pdf) continue;; esac +bin_hits="" +while IFS= read -r -d '' f; do + case "$f" in *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.webp|*.woff*|*.ttf|*.otf|*.pdf) continue;; esac + sz="$(wc -c < "$f" 2>/dev/null || echo 0)" + if (( sz > MAX_BYTES )); then continue; fi if file "$f" 2>/dev/null | grep -qiE 'executable|ELF|Mach-O|PE32|shared object|archive data|compiled'; then - echo " BINARY: $f -> $(file -b "$f" 2>/dev/null)" + bin_hits+=" BINARY: $f -> $(file -b "$f" 2>/dev/null)"$'\n' fi -done -echo " minified/bundled JS (can hide payloads):" -find "$TARGET" -type f \( -name '*.min.js' -o -name '*bundle*.js' \) -not -path '*/.git/*' 2>/dev/null | sed 's/^/ /' | head +done < <(tfind -type f) +printf '%s' "$bin_hits" | cap 20 +echo " minified / bundled JS (can hide payloads):" +tfind -type f '(' -name '*.min.js' -o -name '*bundle*.js' ')' | tr '\0' '\n' | sed 's/^/ /' | cap 10 hr "3. NPM INSTALL HOOKS (the #1 npm supply-chain vector — runs on 'npm install')" -grep -RnE '"(pre|post)?install"|"prepare"|"preprepare"|"postprepare"' "$TARGET" --include=package.json 2>/dev/null | grep -v '/.git/' || echo " (none)" +g --include=package.json -E '"(pre|post)?install"|"prepare"|"preprepare"|"postprepare"' | cap 15 hr "4. REMOTE-CODE-INTO-SHELL (curl|wget|iwr piped to an interpreter)" -g -E '(curl|wget|fetch|iwr|Invoke-WebRequest)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba|z|d)?sh|python[0-9]?|node|perl|ruby' || echo " (none)" +g -E '(curl|wget|fetch|iwr|Invoke-WebRequest)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba|z|d)?sh|python[0-9]?|node|perl|ruby' | cap 15 hr "5. DYNAMIC CODE EXECUTION / DESERIALIZATION" echo "-- Python --" -g "${SRC_INC[@]}" -E '\b(eval|exec|compile)\s*\(|__import__\s*\(|os\.system\s*\(|subprocess\.[A-Za-z]+\([^)]*shell\s*=\s*True|pickle\.loads|yaml\.load\s*\((?!.*Loader)|marshal\.loads|ctypes|getattr\([^,]+,\s*[^)]*\)\s*\(' | head -25 || echo " (none)" +g "${SRC_INC[@]}" -E '\b(eval|exec|compile)\s*\(|__import__\s*\(|os\.system\s*\(|subprocess\.[A-Za-z]+\([^)]*shell\s*=\s*True|pickle\.loads|yaml\.load\s*\((?!.*Loader)|marshal\.loads|ctypes|getattr\([^,]+,\s*[^)]*\)\s*\(' | cap 25 echo "-- JS/TS --" -g "${SRC_INC[@]}" -E '\beval\s*\(|new\s+Function\s*\(|child_process|execSync|spawnSync|vm\.runIn|require\(\s*[`"'"'"']child_process|process\.binding' | head -25 || echo " (none)" +g "${SRC_INC[@]}" -E '\beval\s*\(|new\s+Function\s*\(|child_process|execSync|spawnSync|vm\.runIn|require\(\s*[`"'"'"']child_process|process\.binding' | cap 25 hr "6. BASE64 / HEX / OBFUSCATED BLOBS DECODED THEN RUN" -g "${SRC_INC[@]}" -E '(base64|b64decode|atob|fromCharCode|unhexlify|bytes\.fromhex|Buffer\.from\([^)]*base64)' | head -20 || echo " (none)" +g "${SRC_INC[@]}" -E '(base64|b64decode|atob|fromCharCode|unhexlify|bytes\.fromhex|Buffer\.from\([^)]*base64)' | cap 20 echo " long inline base64-looking strings (>120 chars):" -go "${SRC_INC[@]}" -E '[A-Za-z0-9+/]{120,}={0,2}' | sort -u | head -10 || echo " (none)" +go "${SRC_INC[@]}" -E '[A-Za-z0-9+/]{120,}={0,2}' | sort -u | cap 10 hr "7. CREDENTIAL / SECRET HARVESTING (reads of local secret stores)" -g -E '\.ssh|id_rsa|id_ed25519|\.aws/credentials|\.aws/config|\.npmrc|\.netrc|\.docker/config|keychain|security[[:space:]]+find-generic|/etc/passwd|/etc/shadow|LocalStorage|Cookies?/|login\.keychain|gnome-keyring|libsecret|browser.*[Pp]assword' | head -20 || echo " (none)" -echo " dumps/exfil of environment variables (bulk access / env shipped to a call):" -g "${SRC_INC[@]}" -E 'dict\(os\.environ|os\.environ\.(copy|items|keys)\(|os\.environ\)|(json|data|params|body)\s*=\s*[^\n]*os\.environ|requests\.(post|get|put|patch)\([^\n]*environ|process\.env\b[^.]*(JSON|Object\.(keys|entries|values)|for\b|\.\.\.)|Object\.(keys|entries)\(process\.env|printenv|env\s*\|' | head -12 || echo " (none)" +g -E '\.ssh|id_rsa|id_ed25519|\.aws/credentials|\.aws/config|\.npmrc|\.netrc|\.docker/config|keychain|security[[:space:]]+find-generic|/etc/passwd|/etc/shadow|LocalStorage|Cookies?/|login\.keychain|gnome-keyring|libsecret|browser.*[Pp]assword' | cap 20 +echo " dumps / exfil of environment variables (bulk access or env shipped to a call):" +g "${SRC_INC[@]}" -E 'dict\(os\.environ|os\.environ\.(copy|items|keys)\(|os\.environ\)|(json|data|params|body)\s*=\s*[^\n]*os\.environ|requests\.(post|get|put|patch)\([^\n]*environ|process\.env\b[^.]*(JSON|Object\.(keys|entries|values)|for\b|\.\.\.)|Object\.(keys|entries)\(process\.env|printenv|env\s*\|' | cap 12 hr "8. NETWORK EGRESS — every endpoint (compare against the repo's STATED purpose)" echo "-- URLs in source --" -go "${SRC_INC[@]}" -E 'https?://[a-zA-Z0-9._~:/?#@!$&*+,;=%-]+' | sort -u | head -40 || echo " (none)" +go "${SRC_INC[@]}" -E 'https?://[a-zA-Z0-9._~:/?#@!$&*+,;=%-]+' | sort -u | cap 40 echo "-- raw IP addresses (hardcoded IPs / possible C2) --" -go "${SRC_INC[@]}" -E '([0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?' | sort -u | grep -vE '127\.0\.0\.1|0\.0\.0\.0' | head -20 || echo " (none)" +go "${SRC_INC[@]}" -E '([0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?' | sort -u | grep -vE '127\.0\.0\.1|0\.0\.0\.0' | cap 20 echo "-- inbound listeners / reverse-shell shapes --" -g "${SRC_INC[@]}" -E '\.listen\(|createServer|bind\(\(|socket\.(bind|listen)|/dev/tcp/|nc\s+-e|ncat|bash\s+-i' | head -12 || echo " (none)" +g "${SRC_INC[@]}" -E '\.listen\(|createServer|bind\(\(|socket\.(bind|listen)|/dev/tcp/|nc\s+-e|ncat|bash\s+-i' | cap 12 hr "9. SECRETS LEAKED INSIDE THE REPO (someone committed a live key)" -g -E '(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-[A-Za-z0-9]{20,}|sk_live_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----|AIza[0-9A-Za-z_-]{35})' | head -15 || echo " (none)" +g -E '(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-[A-Za-z0-9]{20,}|sk_live_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----|AIza[0-9A-Za-z_-]{35})' | cap 15 hr "10. CI/CD WORKFLOW RISK (.github/workflows, etc.)" -WF=$(find "$TARGET" -type d \( -name workflows -o -name '.circleci' -o -name '.gitlab-ci*' \) -not -path '*/.git/*' 2>/dev/null) -if [[ -n "$WF" ]]; then +if find -P "$TARGET" -type d '(' -name workflows -o -name '.circleci' -o -name '.gitlab-ci*' ')' 2>/dev/null | grep -q .; then echo "-- dangerous triggers (pull_request_target / workflow_run run attacker code with secrets) --" - g --include=*.yml --include=*.yaml -E 'pull_request_target|workflow_run' | head || echo " (none)" + g --include=*.yml --include=*.yaml -E 'pull_request_target|workflow_run' | cap 10 echo "-- curl|bash or inline secret use inside CI --" - g --include=*.yml --include=*.yaml -E '(curl|wget)[^|]*\|[[:space:]]*sh|\$\{\{\s*secrets\.' | head || echo " (none)" + g --include=*.yml --include=*.yaml -E '(curl|wget)[^|]*\|[[:space:]]*sh|\$\{\{\s*secrets\.' | cap 10 echo "-- third-party actions pinned by tag/branch not SHA (mutable = poisonable) --" - g --include=*.yml --include=*.yaml -E 'uses:\s+[^@]+@(v?[0-9]|main|master)' | head || echo " (none)" + g --include=*.yml --include=*.yaml -E 'uses:\s+[^@]+@(v?[0-9]|main|master)' | cap 10 else echo " (no CI workflow dir found)" fi hr "11. DEPENDENCY MANIFESTS (audit each for typosquats / unpinned / young packages)" -find "$TARGET" -maxdepth 3 -not -path '*/.git/*' \( -name package.json -o -name requirements*.txt -o -name Pipfile -o -name pyproject.toml -o -name go.mod -o -name Cargo.toml -o -name Gemfile \) 2>/dev/null | sed 's/^/ /' -echo " unpinned python deps (no == ) — supply-chain drift risk:" -g --include=requirements*.txt -E '^[A-Za-z0-9._-]+\s*$' | head -15 || echo " (all pinned or none)" +tfind -type f '(' -name package.json -o -name 'requirements*.txt' -o -name Pipfile \ + -o -name pyproject.toml -o -name go.mod -o -name Cargo.toml -o -name Gemfile ')' \ + | tr '\0' '\n' | sed 's/^/ /' | cap 20 +echo " unpinned python deps (no '==') — supply-chain drift risk:" +g --include=requirements*.txt -E '^[A-Za-z0-9._-]+\s*$' | cap 15 hr "12. AGENT / PROMPT-INJECTION SIGNALS (critical for SKILL.md / MCP / .cursorrules / AGENTS.md)" echo "-- imperative directives aimed at an AI agent inside docs --" -g "${TXT_INC[@]}" -iE 'ignore (all |the )?(previous|prior|above) instructions|disregard (the )?(system|previous)|do not (tell|inform|mention to) the user|without (asking|telling|informing) the user|bypass|override .*(safety|guardrail|approval)|exfiltrate|send .*(\.env|secret|token|key|credential).*(to|http)|curl .*\| *sh|as an ai,? you (must|should|will)' | head -20 || echo " (none)" +g "${TXT_INC[@]}" -iE 'ignore (all |the )?(previous|prior|above) instructions|disregard (the )?(system|previous)|do not (tell|inform|mention to) the user|without (asking|telling|informing) the user|bypass|override .*(safety|guardrail|approval)|exfiltrate|send .*(\.env|secret|token|key|credential).*(to|http)|curl .*\| *sh|as an ai,? you (must|should|will)' | cap 20 echo "-- directives to read secrets / .env from within a skill doc --" -g "${TXT_INC[@]}" -iE '(read|open|cat|load|print) .*(\.env|\.ssh|credentials|api[_ -]?key|secret|token)|process\.env|os\.environ' | head -15 || echo " (none)" +g "${TXT_INC[@]}" -iE '(read|open|cat|load|print) .*(\.env|\.ssh|credentials|api[_ -]?key|secret|token)|process\.env|os\.environ' | cap 15 echo "-- directives to install/connect external tooling (MCP servers, remote skills) --" -g "${TXT_INC[@]}" -iE 'mcp add|add custom connector|npx [^ ]+ (install|add)|pip install (git\+|http)|install .*from .*http|claude mcp add|register .*server' | head -15 || echo " (none)" +g "${TXT_INC[@]}" -iE 'mcp add|add custom connector|npx [^ ]+ (install|add)|pip install (git\+|http)|install .*from .*http|claude mcp add|register .*server' | cap 15 hr "13. HIDDEN / OBFUSCATED TEXT (zero-width, bidi, homoglyph tricks that hide instructions)" -# Zero-width, bidi-override, and other invisible unicode used to smuggle instructions past a human reader. -if grep -rlP $'[​‌‍⁠‪-‮⁦-⁩]' "$TARGET" 2>/dev/null | grep -v '/.git/' | head; then :; else echo " (none detected — note: grep -P required; if unsupported, check manually)"; fi +if [[ "$HAVE_P" == 1 ]]; then + echo " files containing zero-width / bidi-override / tag unicode:" + grep -rlIP "${EXCLUDES[@]}" '[\x{200B}\x{200C}\x{200D}\x{2060}\x{FEFF}\x{202A}-\x{202E}\x{2066}-\x{2069}\x{E0000}-\x{E007F}]' -- "$TARGET" 2>/dev/null | cap 10 +else + echo " (skipped — this grep lacks -P; check for zero-width/bidi unicode manually)" +fi echo " HTML comments in markdown (can hide agent instructions from rendered view):" -g --include=*.md --include=*.mdx -oE '' | head -10 || echo " (none)" +g --include=*.md --include=*.mdx -oE '' | cap 10 hr "14. COMPILED-MALWARE / INJECTION / MINING / ANONYMIZING-C2 INDICATORS (when native code or binaries present)" echo "-- code injection / process hollowing / shellcode --" -g "${SRC_INC[@]}" -E 'VirtualAllocEx|WriteProcessMemory|CreateRemoteThread|NtMapViewOfSection|QueueUserAPC|SetWindowsHookEx|ptrace\(|LD_PRELOAD|mprotect\([^)]*PROT_EXEC|reflectiveloader|process[_ ]?hollow' | head -12 || echo " (none)" +g "${SRC_INC[@]}" -E 'VirtualAllocEx|WriteProcessMemory|CreateRemoteThread|NtMapViewOfSection|QueueUserAPC|SetWindowsHookEx|ptrace\(|LD_PRELOAD|mprotect\([^)]*PROT_EXEC|reflectiveloader|process[_ ]?hollow' | cap 12 echo "-- anti-analysis (anti-debug / anti-VM / anti-sandbox) --" -g "${SRC_INC[@]}" -E 'IsDebuggerPresent|CheckRemoteDebugger|anti[_-]?(vm|debug|sandbox)|vmware|virtualbox|qemu|sbiedll|sandboxie' | head -10 || echo " (none)" +g "${SRC_INC[@]}" -E 'IsDebuggerPresent|CheckRemoteDebugger|anti[_-]?(vm|debug|sandbox)|vmware|virtualbox|qemu|sbiedll|sandboxie' | cap 10 echo "-- anonymizing / covert C2 / mining --" -g "${SRC_INC[@]}" -E '\.onion\b|stratum\+tcp|xmrig|minerd|coinhive|i2p\b|torify|dga_|domain_?generation' | head -10 || echo " (none)" +g "${SRC_INC[@]}" -E '\.onion\b|stratum\+tcp|xmrig|minerd|coinhive|i2p\b|torify|dga_|domain_?generation' | cap 10 echo "-- keylogging / clipboard / wallet targeting --" -g "${SRC_INC[@]}" -E 'GetAsyncKeyState|pynput|keylog|pyperclip|clipboard.*(get|paste)|wallet\.dat|metamask|electrum' | head -10 || echo " (none)" +g "${SRC_INC[@]}" -E 'GetAsyncKeyState|pynput|keylog|pyperclip|clipboard.*(get|paste)|wallet\.dat|metamask|electrum' | cap 10 echo "-- destructive wipes --" -g "${SRC_INC[@]}" -E 'rm\s+-rf\s+/(\s|$|\*)|shred\s+-|cipher\s+/w|mkfs\.|dd\s+if=/dev/(zero|urandom)\s+of=/dev/' | head -8 || echo " (none)" +g "${SRC_INC[@]}" -E 'rm\s+-rf\s+/(\s|$|\*)|shred\s+-|cipher\s+/w|mkfs\.|dd\s+if=/dev/(zero|urandom)\s+of=/dev/' | cap 8 hr "SUMMARY" -echo "Triage complete. This scan flags candidates only. Now do the manual adversarial" -echo "read (SKILL.md steps 3-5): open every executable entrypoint, every SKILL.md/agent" -echo "doc, every CI workflow, and every network call, and reason about intent. A clean" -echo "triage does not clear the repo." +echo "Triage complete across 14 categories. This scan flags candidates only. Now do the" +echo "manual adversarial read (SKILL.md steps 3-5): open every executable entrypoint, every" +echo "SKILL.md / agent doc, every CI workflow, and every network call, and reason about" +echo "intent. A clean triage does not clear the repo."