From a04c2624223784f6c6933723eb7ed10a0c38b509 Mon Sep 17 00:00:00 2001 From: virtualian Date: Sun, 7 Dec 2025 15:43:29 +0000 Subject: [PATCH] Enhance email risk assessment (issue #6) - Add init/refresh commands for external data files - Download disposable email domains list (4,932 domains) - Download Tranco top 100k for domain reputation - Detect disposable, privacy-relay, and suspicious email patterns - Add positive trust signals for established domains - Add domain caching to avoid repeated lookups - Add Email Domain Analysis section to reports - Warn if data files stale (>30 days) --- npm-scanner.sh | 212 ++++++++++++++++ scripts/audit-installed-packages.sh | 113 ++++++++- scripts/npm-audit-lib.sh | 361 ++++++++++++++++++++++++++++ scripts/npm-security-audit.sh | 22 ++ scripts/package-validator.js | 230 ++++++++++++++++-- 5 files changed, 913 insertions(+), 25 deletions(-) diff --git a/npm-scanner.sh b/npm-scanner.sh index 4104f43..37327fb 100755 --- a/npm-scanner.sh +++ b/npm-scanner.sh @@ -14,6 +14,15 @@ show_version() { echo "npm-scanner $VERSION ($RELEASE_DATE)" } +# Data directory for downloaded lists +DATA_DIR="$HOME/.npm-scanner/data" +DATA_REFRESH_FILE="$DATA_DIR/.last-refresh" +DATA_STALE_DAYS=30 + +# URLs for data files +DISPOSABLE_DOMAINS_URL="https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf" +TRANCO_LIST_URL="https://tranco-list.eu/top-1m.csv.zip" + show_help() { cat << 'EOF' npm-scanner - Security toolkit for npm supply chain protection @@ -21,6 +30,8 @@ npm-scanner - Security toolkit for npm supply chain protection Usage: npm-scanner.sh [options] Commands: + init Download required data files (run once after install) + refresh Update data files to latest versions scan Scan installed packages or project dependencies for security issues validate Validate a package before installation list-iocs Display all current indicators of compromise (IOCs) @@ -29,6 +40,8 @@ Commands: version Show version information Examples: + npm-scanner.sh init # Initial setup + npm-scanner.sh refresh # Update data files npm-scanner.sh scan --global # Scan globally installed packages npm-scanner.sh scan --project ~/code # Scan project dependencies npm-scanner.sh scan --local ~/projects # Scan node_modules directories @@ -43,11 +56,203 @@ Documentation: docs/README.md EOF } +# Check if data files exist and are not stale +# Returns: 0 if OK, 1 if missing, 2 if stale +check_data_status() { + local disposable_file="$DATA_DIR/disposable-email-domains.txt" + local tranco_file="$DATA_DIR/tranco-top-100k.csv" + + if [ ! -f "$disposable_file" ] || [ ! -f "$tranco_file" ]; then + return 1 # Missing + fi + + if [ ! -f "$DATA_REFRESH_FILE" ]; then + return 2 # No refresh date, consider stale + fi + + local last_refresh=$(cat "$DATA_REFRESH_FILE" 2>/dev/null) + local last_refresh_ts=$(date -j -f "%Y-%m-%d" "$last_refresh" +%s 2>/dev/null || date -d "$last_refresh" +%s 2>/dev/null || echo 0) + local now_ts=$(date +%s) + local age_days=$(( (now_ts - last_refresh_ts) / 86400 )) + + if [ "$age_days" -gt "$DATA_STALE_DAYS" ]; then + return 2 # Stale + fi + + return 0 # OK +} + +# Get human-readable data status +get_data_status_message() { + local disposable_file="$DATA_DIR/disposable-email-domains.txt" + + if [ ! -f "$disposable_file" ]; then + echo "not initialized" + return + fi + + if [ ! -f "$DATA_REFRESH_FILE" ]; then + echo "unknown (no refresh date)" + return + fi + + local last_refresh=$(cat "$DATA_REFRESH_FILE" 2>/dev/null) + local last_refresh_ts=$(date -j -f "%Y-%m-%d" "$last_refresh" +%s 2>/dev/null || date -d "$last_refresh" +%s 2>/dev/null || echo 0) + local now_ts=$(date +%s) + local age_days=$(( (now_ts - last_refresh_ts) / 86400 )) + + if [ "$age_days" -gt "$DATA_STALE_DAYS" ]; then + echo "stale (last refresh: $last_refresh, $age_days days ago)" + else + echo "current (last refresh: $last_refresh)" + fi +} + +# Warn if data is missing or stale +warn_data_status() { + check_data_status + local status=$? + + if [ $status -eq 1 ]; then + echo "" + echo "WARNING: Data files not initialized." + echo "Run 'npm-scanner.sh init' to download required data files." + echo "" + return 1 + elif [ $status -eq 2 ]; then + echo "" + echo "WARNING: Data files are stale ($(get_data_status_message))." + echo "Run 'npm-scanner.sh refresh' to update." + echo "" + fi + return 0 +} + +# Check if required tools are available +check_download_prerequisites() { + local missing="" + if ! command -v curl >/dev/null 2>&1; then + missing="$missing curl" + fi + if ! command -v unzip >/dev/null 2>&1; then + missing="$missing unzip" + fi + if [ -n "$missing" ]; then + echo "ERROR: Missing required tools:$missing" + echo "Please install them and try again." + return 1 + fi + return 0 +} + +# Download data files +download_data_files() { + local verbose=${1:-true} + + # Check prerequisites + if ! check_download_prerequisites; then + return 1 + fi + + mkdir -p "$DATA_DIR" + + local disposable_file="$DATA_DIR/disposable-email-domains.txt" + local tranco_file="$DATA_DIR/tranco-top-100k.csv" + + # Download disposable email domains + [ "$verbose" = true ] && echo "Downloading disposable email domains list..." + if curl -sL "$DISPOSABLE_DOMAINS_URL" -o "$disposable_file.tmp"; then + local count=$(wc -l < "$disposable_file.tmp" | tr -d ' ') + mv "$disposable_file.tmp" "$disposable_file" + [ "$verbose" = true ] && echo " Downloaded $count domains" + else + [ "$verbose" = true ] && echo " ERROR: Failed to download disposable domains list" + rm -f "$disposable_file.tmp" + return 1 + fi + + # Download Tranco top 1M domains (zip), extract and keep top 100k + [ "$verbose" = true ] && echo "Downloading Tranco domain rankings..." + local tranco_zip="$DATA_DIR/tranco.zip" + if curl -sL "$TRANCO_LIST_URL" -o "$tranco_zip"; then + # Unzip, convert CRLF to LF, and keep top 100k only + if unzip -p "$tranco_zip" top-1m.csv 2>/dev/null | tr -d '\r' | head -100000 > "$tranco_file.tmp"; then + local count=$(wc -l < "$tranco_file.tmp" | tr -d ' ') + mv "$tranco_file.tmp" "$tranco_file" + rm -f "$tranco_zip" + [ "$verbose" = true ] && echo " Downloaded top $count domains" + else + [ "$verbose" = true ] && echo " ERROR: Failed to extract Tranco list" + rm -f "$tranco_zip" "$tranco_file.tmp" + return 1 + fi + else + [ "$verbose" = true ] && echo " ERROR: Failed to download Tranco list" + rm -f "$tranco_zip" + return 1 + fi + + # Record refresh date + date +%Y-%m-%d > "$DATA_REFRESH_FILE" + + [ "$verbose" = true ] && echo "" + [ "$verbose" = true ] && echo "Data files updated: $(date +%Y-%m-%d)" + [ "$verbose" = true ] && echo "" + [ "$verbose" = true ] && echo "Sources:" + [ "$verbose" = true ] && echo " - Disposable domains: github.com/disposable-email-domains/disposable-email-domains" + [ "$verbose" = true ] && echo " - Domain rankings: tranco-list.eu" + + return 0 +} + +cmd_init() { + echo "" + echo "npm-scanner - Initializing data files" + echo "" + + if check_data_status; then + echo "Data files already initialized ($(get_data_status_message))." + echo "Use 'npm-scanner.sh refresh' to update." + return 0 + fi + + download_data_files + local status=$? + + if [ $status -eq 0 ]; then + echo "" + echo "Initialization complete. You can now use npm-scanner." + fi + + return $status +} + +cmd_refresh() { + echo "" + echo "npm-scanner - Refreshing data files" + echo "" + + local current_status=$(get_data_status_message) + echo "Current status: $current_status" + echo "" + + download_data_files + local status=$? + + if [ $status -eq 0 ]; then + echo "" + echo "Refresh complete." + fi + + return $status +} + show_scan_help() { "$SCRIPTS_DIR/audit-installed-packages.sh" --help } cmd_scan() { + warn_data_status || return 1 "$SCRIPTS_DIR/audit-installed-packages.sh" "$@" } @@ -75,6 +280,7 @@ cmd_validate() { show_validate_help exit 0 fi + warn_data_status || return 1 node "$SCRIPTS_DIR/package-validator.js" "$@" } @@ -218,6 +424,12 @@ COMMAND="$1" shift case "$COMMAND" in + init) + cmd_init + ;; + refresh) + cmd_refresh + ;; scan) cmd_scan "$@" ;; diff --git a/scripts/audit-installed-packages.sh b/scripts/audit-installed-packages.sh index 799919a..64b7829 100755 --- a/scripts/audit-installed-packages.sh +++ b/scripts/audit-installed-packages.sh @@ -229,6 +229,8 @@ audit_single_package() { local SCRIPTS_PRESENT=false local RECENT_PRESENT=false local FETCH_PRESENT=false + local SUSPICIOUS_EMAIL_PRESENT=false + local SUSPICIOUS_EMAIL_DETAILS="" if echo "$CLEAN" | grep -q "No HTTP URL dependencies"; then HTTP_PRESENT=false @@ -244,6 +246,11 @@ audit_single_package() { if echo "$CLEAN" | grep -q "Cannot fetch info"; then FETCH_PRESENT=true fi + if echo "$CLEAN" | grep -q "Suspicious email(s) found"; then + SUSPICIOUS_EMAIL_PRESENT=true + # Extract email details from output + SUSPICIOUS_EMAIL_DETAILS=$(echo "$CLEAN" | grep -A10 "Suspicious email(s) found" | grep "^ *-" | sed 's/^ *- //' | head -5) + fi # Get maintainer status local MAINTAINERS_STATUS="None" @@ -310,10 +317,15 @@ PKGEOF echo "| Fetch warnings | No |" >> "$REPORT_FILE" fi echo "| Maintainers | $MAINTAINERS_STATUS |" >> "$REPORT_FILE" + if $SUSPICIOUS_EMAIL_PRESENT; then + echo "| Suspicious emails | ⚠️ Yes |" >> "$REPORT_FILE" + else + echo "| Suspicious emails | No |" >> "$REPORT_FILE" + fi echo "" >> "$REPORT_FILE" # Add details if any issues - if $SCRIPTS_PRESENT || $RECENT_PRESENT || $FETCH_PRESENT || $HTTP_PRESENT; then + if $SCRIPTS_PRESENT || $RECENT_PRESENT || $FETCH_PRESENT || $HTTP_PRESENT || $SUSPICIOUS_EMAIL_PRESENT; then if $SCRIPTS_PRESENT; then local SLINE=$(echo "$CLEAN" | grep -m1 "Found lifecycle scripts in:" | sed 's/.*in: //') if [ -n "$SLINE" ]; then @@ -333,6 +345,12 @@ PKGEOF if $HTTP_PRESENT; then echo "- HTTP URL dependencies detected" >> "$REPORT_FILE" fi + if $SUSPICIOUS_EMAIL_PRESENT && [ -n "$SUSPICIOUS_EMAIL_DETAILS" ]; then + echo "- Suspicious email(s):" >> "$REPORT_FILE" + echo "$SUSPICIOUS_EMAIL_DETAILS" | while IFS= read -r email_line; do + [ -n "$email_line" ] && echo " - $email_line" >> "$REPORT_FILE" + done + fi echo "" >> "$REPORT_FILE" fi @@ -782,8 +800,9 @@ scan_packages() { local COUNT_RECENT=$(grep -hl "| Recently created | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') local COUNT_FETCH=$(grep -hl "| Fetch warnings | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') local COUNT_SOLO=$(grep -hl "| Maintainers | ⚠️ Solo |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') - local COUNT_TOTAL=$((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO)) - + local COUNT_SUSPICIOUS_EMAIL=$(grep -hl "| Suspicious emails | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') + local COUNT_TOTAL=$((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO + COUNT_SUSPICIOUS_EMAIL)) + # Print summary table to terminal echo "Summary of Findings:" echo "" @@ -794,6 +813,7 @@ scan_packages() { printf "%-25s %s\n" "Recently created" "$COUNT_RECENT" printf "%-25s %s\n" "Fetch warnings" "$COUNT_FETCH" printf "%-25s %s\n" "Solo Maintainers" "$COUNT_SOLO" + printf "%-25s %s\n" "Suspicious emails" "$COUNT_SUSPICIOUS_EMAIL" printf "%-25s %s\n" "-------------------------" "---------------" printf "%-25s %s\n" "TOTAL" "$COUNT_TOTAL" @@ -1022,8 +1042,9 @@ scan_projects() { local COUNT_RECENT=$(grep -hl "| Recently created | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') local COUNT_FETCH=$(grep -hl "| Fetch warnings | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') local COUNT_SOLO=$(grep -hl "| Maintainers | ⚠️ Solo |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') - local COUNT_TOTAL=$((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO)) - + local COUNT_SUSPICIOUS_EMAIL=$(grep -hl "| Suspicious emails | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') + local COUNT_TOTAL=$((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO + COUNT_SUSPICIOUS_EMAIL)) + # Print summary table to terminal echo "Summary of Findings:" echo "" @@ -1034,6 +1055,7 @@ scan_projects() { printf "%-25s %s\n" "Recently created" "$COUNT_RECENT" printf "%-25s %s\n" "Fetch warnings" "$COUNT_FETCH" printf "%-25s %s\n" "Solo Maintainers" "$COUNT_SOLO" + printf "%-25s %s\n" "Suspicious emails" "$COUNT_SUSPICIOUS_EMAIL" printf "%-25s %s\n" "-------------------------" "---------------" printf "%-25s %s\n" "TOTAL" "$COUNT_TOTAL" @@ -1206,6 +1228,7 @@ COUNT_SCRIPTS=$(grep -hl "| Lifecycle scripts | ⚠️ Present |" "$OUTPUT_DIR"/ COUNT_RECENT=$(grep -hl "| Recently created | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') COUNT_FETCH=$(grep -hl "| Fetch warnings | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') COUNT_SOLO=$(grep -hl "| Maintainers | ⚠️ Solo |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') +COUNT_SUSPICIOUS_EMAIL=$(grep -hl "| Suspicious emails | ⚠️ Yes |" "$OUTPUT_DIR"/node-*.md 2>/dev/null | wc -l | tr -d ' ') COUNT_TOTAL=$TOTAL # Risk > 50 summary (all modes) @@ -1276,7 +1299,8 @@ cat >> "$OUTPUT_DIR/audit-report.md" << EOF | Recently created | $COUNT_RECENT | | Fetch warnings | $COUNT_FETCH | | Solo Maintainers | $COUNT_SOLO | -| TOTAL | $((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO)) | +| Suspicious emails | $COUNT_SUSPICIOUS_EMAIL | +| TOTAL | $((COUNT_HTTP + COUNT_SCRIPTS + COUNT_RECENT + COUNT_FETCH + COUNT_SOLO + COUNT_SUSPICIOUS_EMAIL)) | See Package List below for details. @@ -1296,6 +1320,7 @@ EOF grep -q "| Recently created | ⚠️ Yes |" "$report" 2>/dev/null && issues+=("Recently created") grep -q "| Fetch warnings | ⚠️ Yes |" "$report" 2>/dev/null && issues+=("Fetch warnings") grep -q "| Maintainers | ⚠️ Solo |" "$report" 2>/dev/null && issues+=("Solo maintainer") + grep -q "| Suspicious emails | ⚠️ Yes |" "$report" 2>/dev/null && issues+=("Suspicious emails") if [ ${#issues[@]} -gt 0 ]; then PKG=$(basename "$report" .md | sed 's/-[0-9a-f]\{8\}$//' | sed 's/^node-//' | sed 's/_/\//g') @@ -1386,6 +1411,76 @@ EOF echo "" >> "$OUTPUT_DIR/audit-report.md" fi +# Email Domain Analysis +# Extract domains from maintainer data and classify them +if [ -s "$OUTPUT_DIR/.work-files/maintainer-data.tsv" ]; then + TMP_DOMAIN_FILE=$(mktemp) + + # Extract email domains from maintainer data + awk -F'|' '{ + for(i=4; i<=NF; i++) { + if($i != "") { + # Extract domain from email (format: "name " or just "email") + email = $i + gsub(/.*.*/, "", email) + if(match(email, /@[^@]+$/)) { + domain = substr(email, RSTART+1) + gsub(/[[:space:]]/, "", domain) + if(domain != "") print tolower(domain) + } + } + } + }' "$OUTPUT_DIR/.work-files/maintainer-data.tsv" | sort | uniq -c | sort -rn > "$TMP_DOMAIN_FILE" + + DOMAIN_COUNT=$(wc -l < "$TMP_DOMAIN_FILE" | tr -d ' ') + + if [ "$DOMAIN_COUNT" -gt 0 ]; then + cat >> "$OUTPUT_DIR/audit-report.md" << 'EOF' +### Email Domain Analysis + +| Domain | Packages | Reputation | +|--------|----------|------------| +EOF + + head -15 "$TMP_DOMAIN_FILE" | while read count domain; do + # Check domain reputation + reputation="unknown" + if [ -f "$HOME/.npm-scanner/data/tranco-top-100k.csv" ]; then + rank=$(grep -E "^[0-9]+,${domain}$" "$HOME/.npm-scanner/data/tranco-top-100k.csv" 2>/dev/null | head -1 | cut -d',' -f1) + if [ -n "$rank" ]; then + if [ "$rank" -le 1000 ]; then + reputation="top1k (#$rank)" + elif [ "$rank" -le 10000 ]; then + reputation="top10k (#$rank)" + else + reputation="top100k (#$rank)" + fi + fi + fi + + # Check if disposable + if [ -f "$HOME/.npm-scanner/data/disposable-email-domains.txt" ]; then + if grep -Fxq "$domain" "$HOME/.npm-scanner/data/disposable-email-domains.txt" 2>/dev/null; then + reputation="⚠️ disposable" + fi + fi + + # Check if privacy relay + case "$domain" in + privaterelay.appleid.com|icloud.com|duck.com|mozmail.com|relay.firefox.com|protonmail.com|protonmail.ch|proton.me|pm.me|tutanota.com|tutanota.de|tutamail.com|tuta.io) + reputation="privacy-relay" + ;; + esac + + echo "| \`$domain\` | $count | $reputation |" >> "$OUTPUT_DIR/audit-report.md" + done + echo "" >> "$OUTPUT_DIR/audit-report.md" + fi + + rm -f "$TMP_DOMAIN_FILE" +fi + : # Build risk file for maintainer review @@ -1552,6 +1647,7 @@ TMP_PKG_TABLE=$(mktemp) grep -q "| Recently created | ⚠️ Yes |" "$report" && indicators+=(Recent) grep -q "| Fetch warnings | ⚠️ Yes |" "$report" && indicators+=(Fetch) grep -q "| Maintainers | ⚠️ Solo |" "$report" && indicators+=(Solo) + grep -q "| Suspicious emails | ⚠️ Yes |" "$report" && indicators+=(Email) # Fallback risk if indicators exist but parsed risk is 0 if [ ${#indicators[@]} -gt 0 ] && [ "$RISK" -eq 0 ]; then RF=0 @@ -1560,6 +1656,7 @@ TMP_PKG_TABLE=$(mktemp) printf '%s\n' "${indicators[@]}" | grep -q '^Recent$' && RF=$((RF+15)) printf '%s\n' "${indicators[@]}" | grep -q '^Fetch$' && RF=$((RF+10)) printf '%s\n' "${indicators[@]}" | grep -q '^Solo$' && RF=$((RF+10)) + printf '%s\n' "${indicators[@]}" | grep -q '^Email$' && RF=$((RF+20)) RISK=$RF fi if [ ${#indicators[@]} -gt 0 ] && [ "$RISK" -gt 0 ]; then @@ -1569,7 +1666,7 @@ TMP_PKG_TABLE=$(mktemp) printf "%s\t%s\t%03d\t\n" "$SOURCE" "$PKG" "$RISK" >> "$TMP_PKG_TABLE" fi done - + # Group by source path CURRENT_SOURCE="" sort "$TMP_PKG_TABLE" | while IFS=$'\t' read -r source pkg risk list; do @@ -1618,6 +1715,7 @@ else grep -q "| Recently created | ⚠️ Yes |" "$report" && indicators+=(Recent) grep -q "| Fetch warnings | ⚠️ Yes |" "$report" && indicators+=(Fetch) grep -q "| Maintainers | ⚠️ Solo |" "$report" && indicators+=(Solo) + grep -q "| Suspicious emails | ⚠️ Yes |" "$report" && indicators+=(Email) if [ ${#indicators[@]} -gt 0 ] && [ "$RISK" -eq 0 ]; then RF=0 printf '%s\n' "${indicators[@]}" | grep -q '^URL$' && RF=$((RF+50)) @@ -1625,6 +1723,7 @@ else printf '%s\n' "${indicators[@]}" | grep -q '^Recent$' && RF=$((RF+15)) printf '%s\n' "${indicators[@]}" | grep -q '^Fetch$' && RF=$((RF+10)) printf '%s\n' "${indicators[@]}" | grep -q '^Solo$' && RF=$((RF+10)) + printf '%s\n' "${indicators[@]}" | grep -q '^Email$' && RF=$((RF+20)) RISK=$RF fi if [ ${#indicators[@]} -gt 0 ] && [ "$RISK" -gt 0 ]; then diff --git a/scripts/npm-audit-lib.sh b/scripts/npm-audit-lib.sh index 1b7908a..ce5c00b 100755 --- a/scripts/npm-audit-lib.sh +++ b/scripts/npm-audit-lib.sh @@ -55,6 +55,353 @@ export TYPOSQUATTING_TARGETS=( "yargs" "glob" "rimraf" ) +# ============================================================================ +# SUSPICIOUS EMAIL DETECTION +# ============================================================================ + +# Path to data files (stored in user data directory) +DISPOSABLE_DOMAINS_FILE="$HOME/.npm-scanner/data/disposable-email-domains.txt" +TRANCO_DOMAINS_FILE="$HOME/.npm-scanner/data/tranco-top-100k.csv" + +# Domain lookup cache: domain → "result|count" +# Avoids repeated lookups and tracks domain frequency +# Note: Requires bash 4+ for associative arrays +if declare -A DOMAIN_CACHE 2>/dev/null; then + : # Associative arrays supported +else + # Fallback: no caching on older bash + DOMAIN_CACHE_SUPPORTED=false +fi + +# Privacy relay domains - legitimate but anonymous (unverifiable identity) +PRIVACY_RELAY_DOMAINS=( + "privaterelay.appleid.com" + "icloud.com" + "duck.com" + "mozmail.com" + "relay.firefox.com" + "protonmail.com" + "protonmail.ch" + "proton.me" + "pm.me" + "tutanota.com" + "tutanota.de" + "tutamail.com" + "tuta.io" +) + +# Suspicious email patterns (regex) - generic indicators +SUSPICIOUS_EMAIL_PATTERNS=( + "temp" + "disposable" + "trash" + "fake" + "spam" + "throwaway" + "guerrilla" + "mailinator" + "10minute" + "tempmail" + "test[0-9]+" + "noreply" + "no-reply" + "donotreply" +) + +# Extract domain from email address +# Usage: lib_extract_email_domain "user@example.com" +# Output: example.com +lib_extract_email_domain() { + local email="$1" + echo "$email" | sed 's/.*@//' | tr '[:upper:]' '[:lower:]' +} + +# Get cached domain result, or empty if not cached +# Usage: lib_get_domain_cache "gmail.com" +# Output: "result|count" or empty +lib_get_domain_cache() { + local domain="$1" + [ "$DOMAIN_CACHE_SUPPORTED" = "false" ] && return + echo "${DOMAIN_CACHE["$domain"]:-}" +} + +# Update domain cache with result, incrementing count +# Usage: lib_update_domain_cache "gmail.com" "trusted:top1k" +lib_update_domain_cache() { + local domain="$1" + local result="$2" + [ "$DOMAIN_CACHE_SUPPORTED" = "false" ] && return + + local cached="${DOMAIN_CACHE["$domain"]:-}" + + if [ -n "$cached" ]; then + # Increment existing count + local old_result=$(echo "$cached" | cut -d'|' -f1) + local old_count=$(echo "$cached" | cut -d'|' -f2) + local new_count=$((old_count + 1)) + DOMAIN_CACHE["$domain"]="${old_result}|${new_count}" + else + # New entry with count 1 + DOMAIN_CACHE["$domain"]="${result}|1" + fi +} + +# Get domain statistics from cache +# Usage: lib_get_domain_stats +# Output: Lines of "domain|result|count" sorted by count descending +lib_get_domain_stats() { + [ "$DOMAIN_CACHE_SUPPORTED" = "false" ] && return + for domain in "${!DOMAIN_CACHE[@]}"; do + local cached="${DOMAIN_CACHE["$domain"]}" + local result=$(echo "$cached" | cut -d'|' -f1) + local count=$(echo "$cached" | cut -d'|' -f2) + echo "${count}|${domain}|${result}" + done | sort -t'|' -k1 -rn | while IFS='|' read -r count domain result; do + echo "${domain}|${result}|${count}" + done +} + +# Clear domain cache +# Usage: lib_clear_domain_cache +lib_clear_domain_cache() { + [ "$DOMAIN_CACHE_SUPPORTED" = "false" ] && return + DOMAIN_CACHE=() +} + +# Output domain statistics to file for aggregation +# Usage: lib_output_domain_stats +# Note: Writes to NPM_AUDIT_DOMAIN_FILE if set +lib_output_domain_stats() { + [ "$DOMAIN_CACHE_SUPPORTED" = "false" ] && return + if [ -z "$NPM_AUDIT_DOMAIN_FILE" ]; then + return + fi + + for domain in "${!DOMAIN_CACHE[@]}"; do + local cached="${DOMAIN_CACHE["$domain"]}" + local result=$(echo "$cached" | cut -d'|' -f1) + local count=$(echo "$cached" | cut -d'|' -f2) + echo "DOMAIN_DATA|${domain}|${result}|${count}" >> "$NPM_AUDIT_DOMAIN_FILE" + done +} + +# Check if email domain is disposable +# Usage: lib_is_disposable_email "user@tempmail.com" +# Returns: 0 if disposable, 1 otherwise +lib_is_disposable_email() { + local email="$1" + local domain=$(lib_extract_email_domain "$email") + + if [ -z "$domain" ]; then + return 1 + fi + + if [ -f "$DISPOSABLE_DOMAINS_FILE" ]; then + if grep -Fxq "$domain" "$DISPOSABLE_DOMAINS_FILE" 2>/dev/null; then + return 0 + fi + fi + + return 1 +} + +# Check if email domain is a privacy relay service +# Usage: lib_is_privacy_relay_email "user@privaterelay.appleid.com" +# Returns: 0 if privacy relay, 1 otherwise +lib_is_privacy_relay_email() { + local email="$1" + local domain=$(lib_extract_email_domain "$email") + + if [ -z "$domain" ]; then + return 1 + fi + + for relay_domain in "${PRIVACY_RELAY_DOMAINS[@]}"; do + if [ "$domain" = "$relay_domain" ]; then + return 0 + fi + done + + return 1 +} + +# Get domain reputation rank from Tranco list +# Usage: lib_get_domain_rank "google.com" +# Output: rank number (1-100000) or empty if not found +lib_get_domain_rank() { + local domain="$1" + + if [ -z "$domain" ] || [ ! -f "$TRANCO_DOMAINS_FILE" ]; then + echo "" + return + fi + + # Tranco format: rank,domain (exact match on domain) + local rank=$(grep -F ",${domain}" "$TRANCO_DOMAINS_FILE" 2>/dev/null | grep -E "^[0-9]+,${domain}$" | head -1 | cut -d',' -f1) + echo "$rank" +} + +# Get domain reputation category based on Tranco rank +# Usage: lib_get_domain_reputation "google.com" +# Output: "top1k", "top10k", "top100k", or "unknown" +lib_get_domain_reputation() { + local domain="$1" + local rank=$(lib_get_domain_rank "$domain") + + if [ -z "$rank" ]; then + echo "unknown" + return + fi + + if [ "$rank" -le 1000 ]; then + echo "top1k" + elif [ "$rank" -le 10000 ]; then + echo "top10k" + elif [ "$rank" -le 100000 ]; then + echo "top100k" + else + echo "unknown" + fi +} + +# Check if email matches suspicious patterns +# Usage: lib_has_suspicious_email_pattern "testuser123@gmail.com" +# Returns: 0 if suspicious, 1 otherwise +lib_has_suspicious_email_pattern() { + local email="$1" + local email_lower=$(echo "$email" | tr '[:upper:]' '[:lower:]') + + for pattern in "${SUSPICIOUS_EMAIL_PATTERNS[@]}"; do + if echo "$email_lower" | grep -iE "$pattern" > /dev/null 2>&1; then + return 0 + fi + done + + return 1 +} + +# Check domain for all indicators (cached) +# Usage: lib_check_domain "gmail.com" +# Output: "disposable|pattern|privacy-relay|trusted:top1k" etc. +lib_check_domain() { + local domain="$1" + local issues="" + local positives="" + + if [ -z "$domain" ]; then + echo "" + return + fi + + # Check cache first + local cached=$(lib_get_domain_cache "$domain") + if [ -n "$cached" ]; then + local cached_result=$(echo "$cached" | cut -d'|' -f1) + # Update count and return cached result + lib_update_domain_cache "$domain" "$cached_result" + echo "$cached_result" + return + fi + + # Check for disposable domain (high risk) + if [ -f "$DISPOSABLE_DOMAINS_FILE" ] && grep -Fxq "$domain" "$DISPOSABLE_DOMAINS_FILE" 2>/dev/null; then + issues="disposable" + fi + + # Check for privacy relay (low risk - not malicious but unverifiable) + for relay_domain in "${PRIVACY_RELAY_DOMAINS[@]}"; do + if [ "$domain" = "$relay_domain" ]; then + [ -n "$issues" ] && issues="${issues}|" + issues="${issues}privacy-relay" + break + fi + done + + # Check domain reputation (positive indicator) + local reputation=$(lib_get_domain_reputation "$domain") + if [ "$reputation" != "unknown" ]; then + positives="trusted:${reputation}" + fi + + # Combine result + local result="" + if [ -n "$issues" ] && [ -n "$positives" ]; then + result="${issues}|${positives}" + elif [ -n "$issues" ]; then + result="$issues" + elif [ -n "$positives" ]; then + result="$positives" + fi + + # Cache the result + lib_update_domain_cache "$domain" "$result" + + echo "$result" +} + +# Check email for all suspicious indicators (uses domain cache) +# Usage: lib_check_suspicious_email "user@tempmail.com" +# Output: "disposable|pattern|attacker|privacy-relay" (pipe-separated list of issues found, empty if clean) +# May also include "trusted:top1k" etc. for positive reputation +lib_check_suspicious_email() { + local email="$1" + local issues="" + + if [ -z "$email" ]; then + echo "" + return + fi + + local domain=$(lib_extract_email_domain "$email") + + # Get domain-level checks (cached) + local domain_result=$(lib_check_domain "$domain") + if [ -n "$domain_result" ]; then + issues="$domain_result" + fi + + # Check for suspicious patterns in full email (not cached - email specific) + if lib_has_suspicious_email_pattern "$email"; then + [ -n "$issues" ] && issues="${issues}|" + issues="${issues}pattern" + fi + + # Check for known attacker patterns (not cached - email specific) + if lib_check_attacker_pattern "$email"; then + [ -n "$issues" ] && issues="${issues}|" + issues="${issues}attacker" + fi + + echo "$issues" +} + +# Check all maintainer emails for a package +# Usage: lib_check_maintainer_emails "json_string" +# Output: Structured data: total_checked|suspicious_count|email1:issue1,issue2|email2:issue1|... +lib_check_maintainer_emails() { + local info="$1" + local total=0 + local suspicious=0 + local details="" + + # Extract emails from maintainers array + local emails=$(echo "$info" | jq -r '.maintainers | if type == "array" then .[] | if type == "string" then . elif type == "object" then .email // empty else empty end else empty end' 2>/dev/null) + + while IFS= read -r email; do + [ -z "$email" ] && continue + total=$((total + 1)) + + local issues=$(lib_check_suspicious_email "$email") + if [ -n "$issues" ]; then + suspicious=$((suspicious + 1)) + [ -n "$details" ] && details="${details}|" + details="${details}${email}:${issues}" + fi + done <<< "$emails" + + echo "${total}|${suspicious}|${details}" +} + # ============================================================================ # CACHE MANAGEMENT # ============================================================================ @@ -900,4 +1247,18 @@ export -f lib_get_template_path export -f lib_render_template export -f lib_render_template_to_file export -f lib_check_prerequisites +export -f lib_extract_email_domain +export -f lib_get_domain_cache +export -f lib_update_domain_cache +export -f lib_get_domain_stats +export -f lib_clear_domain_cache +export -f lib_output_domain_stats +export -f lib_is_disposable_email +export -f lib_is_privacy_relay_email +export -f lib_get_domain_rank +export -f lib_get_domain_reputation +export -f lib_check_domain +export -f lib_has_suspicious_email_pattern +export -f lib_check_suspicious_email +export -f lib_check_maintainer_emails diff --git a/scripts/npm-security-audit.sh b/scripts/npm-security-audit.sh index d0e46e2..970e58a 100755 --- a/scripts/npm-security-audit.sh +++ b/scripts/npm-security-audit.sh @@ -141,6 +141,28 @@ check_package_info() { echo -e "${YELLOW} ℹ Single maintainer: $pkg${NC}" fi fi + + # Check for suspicious emails + local email_check=$(lib_check_maintainer_emails "$info") + local email_total=$(echo "$email_check" | cut -d'|' -f1) + local email_suspicious=$(echo "$email_check" | cut -d'|' -f2) + local email_details=$(echo "$email_check" | cut -d'|' -f3-) + + if [ "$email_suspicious" -gt 0 ]; then + echo -e "${RED} ⚠ Suspicious email(s) found in: $pkg${NC}" + # Parse and display each suspicious email + echo "$email_details" | tr '|' '\n' | while IFS=':' read -r email issues; do + [ -z "$email" ] && continue + local issue_desc="" + case "$issues" in + *attacker*) issue_desc="known attacker pattern" ;; + *disposable*) issue_desc="disposable email domain" ;; + *pattern*) issue_desc="suspicious pattern" ;; + *) issue_desc="suspicious" ;; + esac + echo -e "${RED} - $email ($issue_desc)${NC}" + done + fi } if [ -f "package.json" ]; then diff --git a/scripts/package-validator.js b/scripts/package-validator.js index ecc2501..3c89e55 100644 --- a/scripts/package-validator.js +++ b/scripts/package-validator.js @@ -9,11 +9,117 @@ */ const https = require('https'); +const fs = require('fs'); +const path = require('path'); const { exec } = require('child_process'); const util = require('util'); const execPromise = util.promisify(exec); +// Load disposable email domains list (stored in user data directory) +const DATA_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.npm-scanner', 'data'); +const DISPOSABLE_DOMAINS_FILE = path.join(DATA_DIR, 'disposable-email-domains.txt'); +const TRANCO_DOMAINS_FILE = path.join(DATA_DIR, 'tranco-top-100k.csv'); + +let DISPOSABLE_DOMAINS = new Set(); +let TRANCO_DOMAINS = new Map(); // domain -> rank + +try { + if (fs.existsSync(DISPOSABLE_DOMAINS_FILE)) { + const content = fs.readFileSync(DISPOSABLE_DOMAINS_FILE, 'utf8'); + DISPOSABLE_DOMAINS = new Set(content.split('\n').map(d => d.trim().toLowerCase()).filter(d => d)); + } +} catch (err) { + // Silently continue if file not found +} + +try { + if (fs.existsSync(TRANCO_DOMAINS_FILE)) { + const content = fs.readFileSync(TRANCO_DOMAINS_FILE, 'utf8'); + content.split('\n').forEach(line => { + const [rank, domain] = line.split(','); + if (rank && domain) { + TRANCO_DOMAINS.set(domain.trim().toLowerCase(), parseInt(rank, 10)); + } + }); + } +} catch (err) { + // Silently continue if file not found +} + +// Privacy relay domains - legitimate but anonymous +const PRIVACY_RELAY_DOMAINS = new Set([ + 'privaterelay.appleid.com', + 'icloud.com', + 'duck.com', + 'mozmail.com', + 'relay.firefox.com', + 'protonmail.com', + 'protonmail.ch', + 'proton.me', + 'pm.me', + 'tutanota.com', + 'tutanota.de', + 'tutamail.com', + 'tuta.io' +]); + +// Domain cache: domain -> { result: string, count: number } +const DOMAIN_CACHE = new Map(); + +// Check domain with caching +function checkDomainCached(domain) { + if (!domain) return { result: null, isNew: false }; + + // Check cache + if (DOMAIN_CACHE.has(domain)) { + const cached = DOMAIN_CACHE.get(domain); + cached.count++; + return { result: cached.result, isNew: false }; + } + + // Perform checks + const issues = []; + const positives = []; + + // Disposable check + if (DISPOSABLE_DOMAINS.has(domain)) { + issues.push('disposable'); + } + + // Privacy relay check + if (PRIVACY_RELAY_DOMAINS.has(domain)) { + issues.push('privacy-relay'); + } + + // Tranco reputation check + if (TRANCO_DOMAINS.has(domain)) { + const rank = TRANCO_DOMAINS.get(domain); + if (rank <= 1000) { + positives.push('trusted:top1k'); + } else if (rank <= 10000) { + positives.push('trusted:top10k'); + } else if (rank <= 100000) { + positives.push('trusted:top100k'); + } + } + + // Combine result + const result = [...issues, ...positives].join('|') || null; + + // Cache it + DOMAIN_CACHE.set(domain, { result, count: 1 }); + + return { result, isNew: true }; +} + +// Get domain statistics +function getDomainStats() { + return Array.from(DOMAIN_CACHE.entries()) + .map(([domain, { result, count }]) => ({ domain, result, count })) + .sort((a, b) => b.count - a.count); +} + const COLORS = { RED: '\x1b[31m', YELLOW: '\x1b[33m', @@ -28,13 +134,38 @@ const RISK_WEIGHTS = { MALICIOUS_IP: 100, ATTACKER_PATTERN: 100, HTTP_DEPENDENCY: 50, + DISPOSABLE_EMAIL: 40, + SUSPICIOUS_EMAIL_PATTERN: 30, + PRIVACY_RELAY_EMAIL: 15, NEW_PACKAGE: 20, SINGLE_MAINTAINER: 10, LOW_DOWNLOADS: 15, INSTALL_SCRIPTS: 25, - RECENT_VERSION: 10 + RECENT_VERSION: 10, + // Positive modifiers (reduce risk) + TRUSTED_TOP1K: -10, + TRUSTED_TOP10K: -5, + TRUSTED_TOP100K: -2 }; +// Suspicious email patterns (regex) - enhanced detection +const SUSPICIOUS_EMAIL_PATTERNS = [ + /temp/i, + /disposable/i, + /trash/i, + /fake/i, + /spam/i, + /throwaway/i, + /guerrilla/i, + /mailinator/i, + /10minute/i, + /tempmail/i, + /test\d+/i, + /noreply/i, + /no-reply/i, + /donotreply/i +]; + // ============================================================================ // INDICATORS OF COMPROMISE (IOCs) // ============================================================================ @@ -171,42 +302,105 @@ class PackageValidator { ); } - // Check for suspicious email patterns (generic) + // Check emails using cached domain lookups maintainers.forEach(m => { - if (m.email && m.email.match(/temp|disposable|trash|fake|test\d+/i)) { - this.addFinding( - 'HIGH', - 'Maintainer Email', - `Suspicious email pattern: ${m.email}`, - RISK_WEIGHTS.SINGLE_MAINTAINER * 3 - ); + if (!m.email) return; + + const domain = m.email.split('@')[1]?.toLowerCase(); + if (!domain) return; + + // Get cached domain result (includes disposable, privacy-relay, trusted checks) + const { result } = checkDomainCached(domain); + + if (result) { + const flags = result.split('|'); + + // Disposable email + if (flags.includes('disposable')) { + this.addFinding( + 'HIGH', + 'Disposable Email', + `Maintainer uses disposable email domain: ${m.email}`, + RISK_WEIGHTS.DISPOSABLE_EMAIL + ); + } + + // Privacy relay + if (flags.includes('privacy-relay')) { + this.addFinding( + 'LOW', + 'Privacy Relay Email', + `Maintainer uses privacy relay: ${m.email}`, + RISK_WEIGHTS.PRIVACY_RELAY_EMAIL + ); + } + + // Trusted domains (positive) + if (flags.includes('trusted:top1k')) { + this.addFinding( + 'INFO', + 'Trusted Domain', + `Maintainer email from top 1k domain: ${domain}`, + RISK_WEIGHTS.TRUSTED_TOP1K + ); + } else if (flags.includes('trusted:top10k')) { + this.addFinding( + 'INFO', + 'Trusted Domain', + `Maintainer email from top 10k domain: ${domain}`, + RISK_WEIGHTS.TRUSTED_TOP10K + ); + } else if (flags.includes('trusted:top100k')) { + this.addFinding( + 'INFO', + 'Trusted Domain', + `Maintainer email from top 100k domain: ${domain}`, + RISK_WEIGHTS.TRUSTED_TOP100K + ); + } } - }); - // Check for known attacker patterns (PhantomRaven IOCs) - maintainers.forEach(m => { - const username = m.name || ''; - const email = m.email || ''; + // Check for suspicious patterns in full email (not domain-cached) + for (const pattern of SUSPICIOUS_EMAIL_PATTERNS) { + if (pattern.test(m.email)) { + this.addFinding( + 'MEDIUM', + 'Suspicious Email', + `Suspicious email pattern detected: ${m.email}`, + RISK_WEIGHTS.SUSPICIOUS_EMAIL_PATTERN + ); + break; + } + } + // Check for known attacker patterns (not domain-cached) ATTACKER_PATTERNS.forEach(pattern => { - if (pattern.test(username)) { + if (pattern.test(m.email)) { this.addFinding( 'CRITICAL', 'Attacker Pattern', - `Maintainer username matches known attacker pattern: ${username}`, + `Maintainer email matches known attacker pattern: ${m.email}`, RISK_WEIGHTS.ATTACKER_PATTERN ); } - if (pattern.test(email)) { + }); + }); + + // Check usernames for attacker patterns + maintainers.forEach(m => { + const username = m.name || ''; + ATTACKER_PATTERNS.forEach(pattern => { + if (pattern.test(username)) { this.addFinding( 'CRITICAL', 'Attacker Pattern', - `Maintainer email matches known attacker pattern: ${email}`, + `Maintainer username matches known attacker pattern: ${username}`, RISK_WEIGHTS.ATTACKER_PATTERN ); } }); }); + } async checkDownloads() {