#!/usr/bin/env bash # npm-security-check.sh # Scans for NPM/Node.js malware indicators on this VM. set -euo pipefail HOSTNAME=$(hostname) DATE=$(date) LOGFILE="${1:-npm_security_check_${HOSTNAME}_$(date +%Y%m%d_%H%M%S).log}" RED='\033[0;31m' YELLOW='\033[1;33m' GREEN='\033[0;32m' BOLD='\033[1m' RESET='\033[0m' ISSUES=0 WARNINGS=0 log() { echo "$*" | tee -a "$LOGFILE"; } header() { log ""; log "=========================================="; log "$*"; log "=========================================="; } ok() { log "$(printf "${GREEN}✓${RESET} %s" "$*")"; } warn() { log "$(printf "${YELLOW}⚠${RESET} %s" "$*")"; (( WARNINGS++ )) || true; } fail() { log "$(printf "${RED}✗${RESET} %s" "$*")"; (( ISSUES++ )) || true; } # ── Header ──────────────────────────────────────────────────────────────────── log "==========================================" log " NPM / Node.js Security Check" log "==========================================" log "Hostname : $HOSTNAME" log "Date : $DATE" log "Log file : $LOGFILE" # ── 1. Global npm packages ──────────────────────────────────────────────────── header "1. Global npm packages" KNOWN_GOOD_GLOBALS="npm corepack" if command -v npm &>/dev/null; then GLOBALS=$(npm list -g --depth=0 2>/dev/null | tail -n +2 | sed 's/.*── //') log "$GLOBALS" # Flag anything that looks like a typosquat or known-bad package SUSPICIOUS_PATTERNS="(plain-crypto-js|axios-[0-9]|node-fetch-[0-9]{3}|colors-js|event-stream|flatmap-stream|ua-parser-js|coa@|rc@[0-9]|nodemailer-[0-9]{3})" HITS=$(echo "$GLOBALS" | grep -E "$SUSPICIOUS_PATTERNS" || true) if [[ -n "$HITS" ]]; then fail "Suspicious global package(s) found:" log "$HITS" else ok "No suspicious global packages" fi else warn "npm not found in PATH" fi # ── 2. Known malicious packages in lock files ───────────────────────────────── header "2. Malicious package names in lock files" BAD_PKGS=( "plain-crypto-js" "axios-proxy" "node-colors" "colors-js" "event-stream" "flatmap-stream" "ua-parser-js" "getcookies" ) LOCKFILES=$(find / -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" \ 2>/dev/null | grep -v node_modules | grep -v "\.vscode-server" | grep -v "\.cache") || true if [[ -z "$LOCKFILES" ]]; then warn "No lock files found to scan" else COUNT=$(echo "$LOCKFILES" | wc -l) log "Scanning $COUNT lock file(s)..." for pkg in "${BAD_PKGS[@]}"; do MATCHES=$(echo "$LOCKFILES" | xargs grep -l "\"$pkg\"" 2>/dev/null || true) if [[ -n "$MATCHES" ]]; then fail "Found '$pkg' in: $MATCHES" fi done ok "No known-malicious package names found" fi # ── 3. Node processes and their origin ──────────────────────────────────────── header "3. Running Node/Next.js processes" NODE_PROCS=$(ps aux | grep -E "[n]ode|[n]ext-server|[n]pm|[n]px|[p]npm" | grep -v grep || true) if [[ -z "$NODE_PROCS" ]]; then ok "No Node.js processes running" else log "$NODE_PROCS" # Check for processes running as root outside of Docker containers ROOT_PROCS=$(echo "$NODE_PROCS" | awk '$1 == "root" {print}' || true) if [[ -n "$ROOT_PROCS" ]]; then # Check if each root process is inside a Docker cgroup (normal) while IFS= read -r proc; do PID=$(echo "$proc" | awk '{print $2}') CGROUP=$(cat /proc/"$PID"/cgroup 2>/dev/null | grep -c "docker" || true) if [[ "$CGROUP" -gt 0 ]]; then ok "PID $PID runs as root but is inside a Docker container (normal)" else warn "PID $PID is a root Node process outside Docker — review manually" log " Command: $(cat /proc/"$PID"/cmdline 2>/dev/null | tr '\0' ' ' || echo 'unreadable')" fi done <<< "$ROOT_PROCS" else ok "No root-owned Node processes outside Docker" fi fi # ── 4. Outbound network connections from Node processes ─────────────────────── header "4. Node process network connections" if command -v lsof &>/dev/null; then NODE_CONNS=$(lsof -i TCP -a -c node -a -s TCP:ESTABLISHED 2>/dev/null || true) if [[ -n "$NODE_CONNS" ]]; then log "$NODE_CONNS" # Flag connections to non-443/80 ports on public IPs UNUSUAL=$(echo "$NODE_CONNS" | awk '!/localhost|127\.0\.0|192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|:443|:80|:22/ && /ESTABLISHED/' || true) if [[ -n "$UNUSUAL" ]]; then warn "Unusual outbound Node connections (non-standard ports or IPs):" log "$UNUSUAL" else ok "Node connections look normal (443/80 or private IPs)" fi else ok "No established TCP connections from node processes" fi else # Fallback to ss ALL_CONNS=$(ss -tnp 2>/dev/null | grep "node\|npm" || true) if [[ -n "$ALL_CONNS" ]]; then log "$ALL_CONNS" else ok "No Node network connections found" fi fi # ── 5. Known C2 indicators ──────────────────────────────────────────────────── header "5. Known C2 / malware indicators" # From previous axios supply-chain attack (Apr 2025 npm incident) C2_IPS=("142.11.206.73" "185.220.101" "194.165.16") C2_DOMAINS=("sfrclak.com" "discordapp.com/api/webhooks" "ngrok.io") ACTIVE_CONNS=$(ss -tn 2>/dev/null || netstat -tn 2>/dev/null || true) FOUND_C2=false for ip in "${C2_IPS[@]}"; do if echo "$ACTIVE_CONNS" | grep -q "$ip"; then fail "Active connection to known C2 IP: $ip" FOUND_C2=true fi done for domain in "${C2_DOMAINS[@]}"; do if echo "$ACTIVE_CONNS" | grep -q "$domain"; then fail "Active connection to suspicious domain: $domain" FOUND_C2=true fi done if ! $FOUND_C2; then ok "No connections to known C2 infrastructure" fi # ── 6. Suspicious processes (miners, RATs) ──────────────────────────────────── header "6. Suspicious process names" SUSPICIOUS_PROCS="(xmrig|minerd|cpuminer|kdevtmpfsi|kinsing|/tmp/[a-z0-9]{8,}|/dev/shm/)" # Match processes whose executable path (field 11) starts with ../ — not args containing ../ HITS=$(ps aux | grep -E "$SUSPICIOUS_PROCS" | grep -v grep || true) DOTDOT=$(ps aux | grep -v grep | awk '$11 ~ /^\.\.\// {print}' || true) [[ -n "$DOTDOT" ]] && HITS="$HITS $DOTDOT" if [[ -n "$HITS" ]]; then fail "Suspicious processes detected:" log "$HITS" else ok "No suspicious process names" fi # ── 7. Suspicious files in temp directories ─────────────────────────────────── header "7. Suspicious files in /tmp and /dev/shm" for dir in /tmp /dev/shm /var/tmp; do EXEC_FILES=$(find "$dir" -type f -executable 2>/dev/null | head -20 || true) JS_FILES=$(find "$dir" -name "*.js" -o -name "*.mjs" 2>/dev/null | head -10 || true) if [[ -n "$EXEC_FILES" ]]; then warn "Executable files in $dir:" log "$EXEC_FILES" fi if [[ -n "$JS_FILES" ]]; then warn "JS files in $dir:" log "$JS_FILES" fi done ok "Temp directory scan complete" # ── 8. npm configuration ────────────────────────────────────────────────────── header "8. npm configuration" if [[ -f "$HOME/.npmrc" ]]; then log "$(cat "$HOME/.npmrc")" # Check for non-official registry ALT_REGISTRY=$(grep -v "^#" "$HOME/.npmrc" | grep "registry" | grep -v "registry.npmjs.org" || true) if [[ -n "$ALT_REGISTRY" ]]; then warn "Non-official npm registry configured: $ALT_REGISTRY" else ok ".npmrc uses official registry" fi else warn "No .npmrc found (registry defaults to npmjs.org — acceptable)" fi # ── 9. Docker containers quick review ──────────────────────────────────────── header "9. Docker containers" if command -v docker &>/dev/null && docker ps &>/dev/null 2>&1; then docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null | tee -a "$LOGFILE" # Flag containers with no named image (just an image ID) UNNAMED=$(docker ps --format "{{.Names}} {{.Image}}" 2>/dev/null | awk '$2 ~ /^[0-9a-f]{12}$/' || true) if [[ -n "$UNNAMED" ]]; then warn "Container(s) using unnamed image IDs (verify these are known):" log "$UNNAMED" else ok "All containers use named images" fi else warn "Docker not available or not accessible" fi # ── 10. Bash history spot-check ─────────────────────────────────────────────── header "10. Bash history — suspicious patterns" HIST_FILE="${HISTFILE:-$HOME/.bash_history}" if [[ -f "$HIST_FILE" ]]; then # Look for obfuscated execution patterns (not internal curl to known Tailscale IPs) SUSPICIOUS_HIST=$(grep -E "(eval\s*\$|base64\s*-d|python.*exec|perl.*eval|/dev/tcp/|bash.*<\(curl.*[^1][^0][^0]\.)" \ "$HIST_FILE" 2>/dev/null | grep -vE "100\.[0-9]+\.[0-9]+\.[0-9]+" | tail -20 || true) if [[ -n "$SUSPICIOUS_HIST" ]]; then warn "Potentially suspicious history entries:" log "$SUSPICIOUS_HIST" else ok "No obviously suspicious history entries" fi else warn "Bash history file not found at $HIST_FILE" fi # ── Summary ──────────────────────────────────────────────────────────────────── header "SUMMARY" log "Scan completed at: $(date)" log "Results saved to : $LOGFILE" log "" if [[ $ISSUES -gt 0 ]]; then log "$(printf "${RED}✗ %d issue(s) found — review output above${RESET}" "$ISSUES")" exit 1 elif [[ $WARNINGS -gt 0 ]]; then log "$(printf "${YELLOW}⚠ Clean but %d warning(s) — review output above${RESET}" "$WARNINGS")" exit 0 else log "$(printf "${GREEN}✓ All checks passed — no indicators of compromise${RESET}")" exit 0 fi