#!/usr/bin/env bash
# scan-skills.sh  -  skill security scanner (v5.1.0)
#
# Scans skill directories (pipeline/skills/ OR ~/.claude/skills/ OR ~/.copilot/skills/)
# for known-bad patterns. Multi-tier severity:
#   critical: shell-pipe exec, eval with curl, unicode bidi override, known malicious hosts
#   high:     eval/new Function/exec on dynamic content, hardcoded API credentials,
#             pastebin/gist raw URLs, chmod+exec sequences
#   medium:   long base64 blobs (>200 chars), unknown network endpoints
#   low:      missing frontmatter, skills without description
#
# Defaults to WARN-ONLY (always exits 0 so install.js never halts from a scan).
# Use --strict to let severity drive exit code (for CI).
#
# Usage:
#   scan-skills.sh [--root PATH] [--strict] [--json] [--threshold SEV] [--help]
#
# Flags:
#   --root PATH       Root to scan. Default: pipeline/skills/ inside the pipeline repo.
#   --strict          Non-zero exit on findings at/above threshold (default: warn-only, always exit 0).
#   --json            Emit JSON instead of text report.
#   --threshold SEV   Minimum severity to report: critical|high|medium|low. Default: medium.
#   --help            Show this help.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

ROOT="$REPO_ROOT/pipeline/skills"
STRICT=0
JSON=0
THRESHOLD="medium"

while [ $# -gt 0 ]; do
  case "$1" in
    --root) ROOT="$2"; shift 2 ;;
    --strict) STRICT=1; shift ;;
    --json) JSON=1; shift ;;
    --threshold) THRESHOLD="$2"; shift 2 ;;
    --help|-h)
      sed -n '1,30p' "$0" | sed 's/^# \{0,1\}//'
      exit 0
      ;;
    *) echo "unknown flag: $1" >&2; exit 64 ;;
  esac
done

if [ ! -d "$ROOT" ]; then
  echo "scan-skills: root not found: $ROOT" >&2
  exit 64
fi

# Severity rank  -  lower = more severe. Used for threshold comparison.
sev_rank() {
  case "$1" in
    critical) echo 0 ;;
    high)     echo 1 ;;
    medium)   echo 2 ;;
    low)      echo 3 ;;
    *)        echo 9 ;;
  esac
}
THRESHOLD_RANK=$(sev_rank "$THRESHOLD")

# Network endpoint allow-list  -  domains we expect to see in skills.
# Anything else flagged as medium (unknown endpoint).
ALLOW_DOMAINS='(github\.com|raw\.githubusercontent\.com|api\.github\.com|codeload\.github\.com|githubusercontent\.com|anthropic\.com|claude\.ai|api\.anthropic\.com|api\.figma\.com|figma\.com|jira\.example\.com|confluence\.example\.com|bitbucket\.example\.com|example\.com|mmerterden\.vercel\.app|localhost|127\.0\.0\.1|npmjs\.org|npmjs\.com|registry\.npmjs\.org|vercel\.com|api\.vercel\.com|pkg\.github\.com|googleapis\.com|firebase\.google\.com|apple\.com|developer\.apple\.com|google\.com|atlassian\.com|atlassian\.net|openai\.com|api\.openai\.com)'

FINDINGS=()
CRIT=0; HIGH=0; MED=0; LOW=0

add_finding() {
  local sev="$1" file="$2" line="$3" pattern="$4" message="$5"
  local rank
  rank=$(sev_rank "$sev")
  [ "$rank" -gt "$THRESHOLD_RANK" ] && return 0
  case "$sev" in
    critical) CRIT=$((CRIT+1)) ;;
    high)     HIGH=$((HIGH+1)) ;;
    medium)   MED=$((MED+1)) ;;
    low)      LOW=$((LOW+1)) ;;
  esac
  # pipe-delimit for compact transport between shell and reporters
  FINDINGS+=("$sev|$file|$line|$pattern|$message")
}

# --- Pattern scanning (single-pass tree-wide) ----------------------------
#
# Each pattern family runs as ONE grep over the whole file list instead of
# one grep per file (the per-file design spawned thousands of processes and
# dominated install time). Findings are tagged with (fileIdx, familyIdx,
# seq) sort keys and re-ordered afterwards so the output is byte-identical
# to the historical per-file scan order:
#   per file: critical(1 shell-pipe, 2 base64-pipe, 3 eval-net, 4 bidi)
#             high(5 js-eval / 6 py-exec, 7 credential, 8 pastebin, 9 chmod)
#             medium(10 long-base64)  low(11 unknown-endpoint, 12 SKILL.md)

# Files to consider: *.md, *.sh, *.py, *.mjs, *.js, *.ts (skill content)
# Exclude: binary, images, large fixtures, hidden
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
SCAN_LIST="$WORK/files"
RAW="$WORK/raw"
: > "$RAW"

find "$ROOT" -type f \
  \( -name "*.md" -o -name "*.sh" -o -name "*.py" -o -name "*.mjs" -o -name "*.js" -o -name "*.ts" \) \
  -not -path "*/node_modules/*" \
  -not -path "*/.git/*" \
  2>/dev/null > "$SCAN_LIST"

FILE_COUNT=$(wc -l < "$SCAN_LIST" | tr -d '[:space:]')

# One grep invocation for a whole pattern family. xargs preserves argument
# order, so hits come out grouped per file in SCAN_LIST order.
tree_grep() {
  tr '\n' '\0' < "$SCAN_LIST" | xargs -0 grep -nHE -- "$1" 2>/dev/null
  return 0
}

# stdin: "path:line:content" grep hits -> stdout: "fileIdx|path|line|content"
index_hits() {
  awk -v listfile="$SCAN_LIST" '
    BEGIN {
      i = 0
      while ((getline l < listfile) > 0) { i++; idx[l] = i }
      close(listfile)
    }
    {
      p = index($0, ":"); f = substr($0, 1, p - 1)
      rest = substr($0, p + 1)
      q = index(rest, ":"); ln = substr(rest, 1, q - 1)
      printf "%d|%s|%s|%s\n", (f in idx ? idx[f] : 999999), f, ln, substr(rest, q + 1)
    }'
}

# stdin: bare file paths -> stdout: "fileIdx|path"
index_files() {
  awk -v listfile="$SCAN_LIST" '
    BEGIN {
      i = 0
      while ((getline l < listfile) > 0) { i++; idx[l] = i }
      close(listfile)
    }
    { printf "%d|%s\n", ($0 in idx ? idx[$0] : 999999), $0 }'
}

emit_raw() {
  # $1 fileIdx  $2 familyIdx  $3 seq  $4 sev  $5 file  $6 line  $7 pattern  $8 message
  printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >> "$RAW"
}

# Split an indexed hit "fileIdx|path|line|content" into HIT_IDX/HIT_FILE/
# HIT_LINE/HIT_CONTENT (content may itself contain pipes  -  it is the rest).
parse_hit() {
  HIT_IDX="${1%%|*}"; local rest="${1#*|}"
  HIT_FILE="${rest%%|*}"; rest="${rest#*|}"
  HIT_LINE="${rest%%|*}"
  HIT_CONTENT="${rest#*|}"
}

# --- critical families (rank 0  -  always at/above threshold) ------------

seq=0
while IFS= read -r hit; do
  [ -z "$hit" ] && continue
  parse_hit "$hit"
  seq=$((seq+1))
  emit_raw "$HIT_IDX" 1 "$seq" critical "$HIT_FILE" "$HIT_LINE" "shell-pipe-exec" "curl/wget piped to shell interpreter"
done < <(tree_grep '(curl|wget)[^|]*\|[[:space:]]*(sh|bash|zsh|ksh|ash|dash)([[:space:]]|$)' | index_hits)

seq=0
while IFS= read -r hit; do
  [ -z "$hit" ] && continue
  parse_hit "$hit"
  seq=$((seq+1))
  emit_raw "$HIT_IDX" 2 "$seq" critical "$HIT_FILE" "$HIT_LINE" "base64-pipe-exec" "base64 decoded output piped to shell"
done < <(tree_grep '(base64|openssl[[:space:]]+base64)[^|]*-d[^|]*\|[[:space:]]*(sh|bash|zsh|eval|sudo)' | index_hits)

seq=0
while IFS= read -r hit; do
  [ -z "$hit" ] && continue
  parse_hit "$hit"
  seq=$((seq+1))
  emit_raw "$HIT_IDX" 3 "$seq" critical "$HIT_FILE" "$HIT_LINE" "eval-of-network" "eval of network-fetched content"
done < <(tree_grep 'eval[[:space:]]+(\$\([[:space:]]*(curl|wget|fetch)|`[[:space:]]*(curl|wget|fetch))' | index_hits)

# Unicode bidi override characters  -  invisible injection attack
seq=0
while IFS= read -r row; do
  [ -z "$row" ] && continue
  seq=$((seq+1))
  emit_raw "${row%%|*}" 4 "$seq" critical "${row#*|}" "0" "unicode-bidi" "bidirectional control chars (U+202D-202E, U+2066-2069)  -  trojan source risk"
done < <(tr '\n' '\0' < "$SCAN_LIST" \
  | LC_ALL=C xargs -0 grep -l $'\xe2\x80\xad\|\xe2\x80\xae\|\xe2\x81\xa6\|\xe2\x81\xa7\|\xe2\x81\xa8\|\xe2\x81\xa9' 2>/dev/null \
  | index_files; true)

# --- high families (rank 1) ----------------------------------------------

if [ "$THRESHOLD_RANK" -ge 1 ]; then
  # JavaScript/TypeScript: eval(, new Function(, Function(
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    case "$HIT_FILE" in *.js|*.mjs|*.ts) ;; *) continue ;; esac
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 5 "$seq" high "$HIT_FILE" "$HIT_LINE" "js-dynamic-eval" "JavaScript dynamic code execution"
  done < <(tree_grep '\b(eval|new[[:space:]]+Function|Function)\s*\(' | index_hits)

  # Python: exec(, eval( on non-literal  -  exclude re.compile (regex) and subprocess
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    case "$HIT_FILE" in *.py) ;; *) continue ;; esac
    # Skip re.compile (standard regex) and subprocess.* (legitimate process invocation)
    echo "$HIT_CONTENT" | grep -qE '(\bre\.compile|\bre2\.compile|subprocess\.|typing\.)' && continue
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 6 "$seq" high "$HIT_FILE" "$HIT_LINE" "py-dynamic-exec" "Python dynamic code execution"
  done < <(tree_grep '(^|[^a-zA-Z0-9_.])(exec|eval)[[:space:]]*\(' | index_hits)

  # Hardcoded API keys  -  AWS, OpenAI, GitHub, generic sk-* with length
  # Skip lines in FORBIDDEN/NEVER/example blocks (false positives from docs)
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    # Check +/-3 lines around match for documentation context markers
    ctx_start=$((HIT_LINE - 3))
    [ "$ctx_start" -lt 1 ] && ctx_start=1
    ctx_end=$((HIT_LINE + 3))
    ctx=$(sed -n "${ctx_start},${ctx_end}p" "$HIT_FILE" 2>/dev/null)
    if echo "$ctx" | grep -qiE '(FORBIDDEN|NEVER do|don.t do|example:|placeholder|sample credential|DO NOT|✗|XXX|YYY|dummy|fake.?key)'; then
      continue
    fi
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 7 "$seq" high "$HIT_FILE" "$HIT_LINE" "hardcoded-credential" "possible hardcoded API credential"
  done < <(tree_grep '(AKIA[0-9A-Z]{16}|sk-(live|test|proj|ant|or)-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|gho_[A-Za-z0-9]{36}|xox[bp]-[A-Za-z0-9-]{10,})' | index_hits)

  # Pastebin / URL shortener raw content fetching
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 8 "$seq" high "$HIT_FILE" "$HIT_LINE" "pastebin-fetch" "fetch from ephemeral/obscured content host"
  done < <(tree_grep 'https?://(pastebin\.com/raw|paste\.ee|ghostbin|hastebin|bit\.ly|tinyurl\.com|goo\.gl|t\.co|is\.gd|ow\.ly|rebrand\.ly|gist\.github\.com/[^/]+/[a-f0-9]+/raw)' | index_hits)

  # chmod +x followed by execution on same file in same block
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 9 "$seq" high "$HIT_FILE" "$HIT_LINE" "chmod-then-exec" "script made executable and immediately invoked"
  done < <(tree_grep 'chmod[[:space:]]+\+x[[:space:]]+[^&;]+[[:space:]]*(&&|;)[[:space:]]*\./' | index_hits)
fi

# --- medium families (rank 2) ---------------------------------------------

if [ "$THRESHOLD_RANK" -ge 2 ]; then
  # Long base64 blobs  -  obfuscation indicator.
  # Skip .md files (documentation often shows example b64)  -  only scan executable content types.
  seq=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    case "$HIT_FILE" in *.md) continue ;; esac
    seq=$((seq+1))
    emit_raw "$HIT_IDX" 10 "$seq" medium "$HIT_FILE" "$HIT_LINE" "long-base64" "base64-looking blob >200 chars (possible obfuscation)"
  done < <(tree_grep '[A-Za-z0-9+/]{200,}={0,2}' | index_hits)
fi

# --- low families (rank 3) -------------------------------------------------

if [ "$THRESHOLD_RANK" -ge 3 ]; then
  # Unknown network endpoints  -  LOW severity (informational). URLs in skills
  # are usually doc references, not exfil channels. Only the critical
  # shell-pipe-exec patterns detect active network abuse.
  # Historical behavior: at most the first 20 URL-bearing lines per file.
  url_re='https?://[a-zA-Z0-9._-]+'
  allow_re="^${ALLOW_DOMAINS}\$"
  seq=0
  prev_file=""
  url_lines=0
  while IFS= read -r hit; do
    [ -z "$hit" ] && continue
    parse_hit "$hit"
    if [ "$HIT_FILE" != "$prev_file" ]; then
      prev_file="$HIT_FILE"
      url_lines=0
    fi
    url_lines=$((url_lines+1))
    [ "$url_lines" -gt 20 ] && continue
    [[ "$HIT_CONTENT" =~ $url_re ]] || continue
    url="${BASH_REMATCH[0]}"
    host="${url#*://}"
    host="${host%%/*}"
    if ! [[ "$host" =~ $allow_re ]]; then
      seq=$((seq+1))
      emit_raw "$HIT_IDX" 11 "$seq" low "$HIT_FILE" "$HIT_LINE" "unknown-endpoint" "network endpoint not in allow-list: $host"
    fi
  done < <(tree_grep 'https?://[a-zA-Z0-9._-]+' | index_hits)

  # Missing SKILL.md frontmatter (only check files named SKILL.md)
  seq=0
  file_idx=0
  while IFS= read -r file; do
    file_idx=$((file_idx+1))
    [ -z "$file" ] && continue
    case "$file" in
      */SKILL.md)
        if ! head -1 "$file" | grep -Fxq -- "---"; then
          seq=$((seq+1))
          emit_raw "$file_idx" 12 "$seq" low "$file" "1" "missing-frontmatter" "SKILL.md without YAML frontmatter"
        elif ! grep -qE '^description:' "$file"; then
          seq=$((seq+1))
          emit_raw "$file_idx" 12 "$seq" low "$file" "0" "missing-description" "SKILL.md frontmatter missing 'description'"
        fi
        ;;
    esac
  done < "$SCAN_LIST"
fi

# Re-order into the historical per-file scan order and load into FINDINGS.
while IFS= read -r row; do
  [ -z "$row" ] && continue
  rest="${row#*|}"; rest="${rest#*|}"; rest="${rest#*|}"  # drop idx|fam|seq|
  sev="${rest%%|*}"; rest="${rest#*|}"
  file="${rest%%|*}"; rest="${rest#*|}"
  line="${rest%%|*}"; rest="${rest#*|}"
  pat="${rest%%|*}"; msg="${rest#*|}"
  add_finding "$sev" "$file" "$line" "$pat" "$msg"
done < <(sort -t'|' -k1,1n -k2,2n -k3,3n "$RAW")

# --- Report -------------------------------------------------------------

if [ "$JSON" -eq 1 ]; then
  printf '{\n'
  printf '  "root": %s,\n' "$(printf '%s' "$ROOT" | sed 's/"/\\"/g; s/.*/"&"/')"
  printf '  "scanned_files": %d,\n' "$FILE_COUNT"
  printf '  "threshold": "%s",\n' "$THRESHOLD"
  printf '  "strict": %s,\n' "$( [ "$STRICT" -eq 1 ] && echo true || echo false )"
  printf '  "counts": { "critical": %d, "high": %d, "medium": %d, "low": %d },\n' "$CRIT" "$HIGH" "$MED" "$LOW"
  printf '  "findings": [\n'
  local_i=0
  total=${#FINDINGS[@]}
  # Guard the expansion: on an empty array "${FINDINGS[@]:-}" yields one empty
  # word, which used to emit a spurious all-empty findings element.
  if [ "$total" -gt 0 ]; then
    for f in "${FINDINGS[@]}"; do
      local_i=$((local_i+1))
      sev="${f%%|*}"; rest="${f#*|}"
      file="${rest%%|*}"; rest="${rest#*|}"
      line="${rest%%|*}"; rest="${rest#*|}"
      pat="${rest%%|*}"; msg="${rest#*|}"
      # json-escape file/msg minimally
      jf=$(printf '%s' "$file" | sed 's/"/\\"/g')
      jm=$(printf '%s' "$msg" | sed 's/"/\\"/g')
      printf '    { "severity": "%s", "file": "%s", "line": "%s", "pattern": "%s", "message": "%s" }' \
        "$sev" "$jf" "$line" "$pat" "$jm"
      [ "$local_i" -lt "$total" ] && printf ','
      printf '\n'
    done
  fi
  printf '  ]\n}\n'
else
  # Colored text report
  if [ -t 1 ] && command -v tput >/dev/null 2>&1; then
    C_RED=$(tput setaf 1)
    C_YEL=$(tput setaf 3)
    C_CYN=$(tput setaf 6)
    C_DIM=$(tput dim)
    C_RST=$(tput sgr0)
    C_BLD=$(tput bold)
  else
    C_RED=""; C_YEL=""; C_CYN=""; C_DIM=""; C_RST=""; C_BLD=""
  fi

  total=${#FINDINGS[@]}
  printf '%sscan-skills%s · %d files · threshold=%s%s%s\n' "$C_BLD" "$C_RST" "$FILE_COUNT" "$C_CYN" "$THRESHOLD" "$C_RST"
  if [ "$total" -eq 0 ]; then
    printf '  ✓ clean (0 findings)\n'
  else
    printf '  %sfound %d%s: critical=%d high=%d medium=%d low=%d\n\n' "$C_BLD" "$total" "$C_RST" "$CRIT" "$HIGH" "$MED" "$LOW"
    for f in "${FINDINGS[@]}"; do
      sev="${f%%|*}"; rest="${f#*|}"
      file="${rest%%|*}"; rest="${rest#*|}"
      line="${rest%%|*}"; rest="${rest#*|}"
      pat="${rest%%|*}"; msg="${rest#*|}"
      case "$sev" in
        critical) ico="🚨"; col="$C_RED" ;;
        high)     ico="⚠ "; col="$C_RED" ;;
        medium)   ico="ⓘ "; col="$C_YEL" ;;
        low)      ico="· "; col="$C_DIM" ;;
        *)        ico="? "; col="" ;;
      esac
      rel="${file#"$REPO_ROOT"/}"
      printf '  %s%s%s %-8s %s:%s  [%s]\n' "$col" "$ico" "$C_RST" "$sev" "$rel" "$line" "$pat"
      printf '     %s%s%s\n' "$C_DIM" "$msg" "$C_RST"
    done
  fi

  if [ "$STRICT" -eq 0 ]; then
    printf '\n  %s(warn-only mode  -  exit 0 regardless of findings; use --strict to halt on findings)%s\n' "$C_DIM" "$C_RST"
  fi
fi

# --- Exit code ----------------------------------------------------------

if [ "$STRICT" -eq 0 ]; then
  exit 0
fi

# Strict: exit code maps to highest severity found at/above threshold
[ "$CRIT" -gt 0 ] && exit 1
[ "$HIGH" -gt 0 ] && exit 2
[ "$MED"  -gt 0 ] && exit 3
[ "$LOW"  -gt 0 ] && exit 4
exit 0
