agentsclimarketplace

Npm security audit

Skill arndvs/ctrlshft/skills/npm-security-audit

Layered security audit on npm/pnpm/yarn projects before installing or running. Use to vet a GitHub repo, npm package, or local project before npm install/start/npx.From its SKILL.md

Install
npx -y skills add arndvs/ctrlshft --skill npm-security-audit

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

17.8 KB, ~4.8k tokens by cl100k_base, as published. Nobody here has run it

npm Security Audit Skill

If running interactively (human present), output "Read npm Security Audit skill." to acknowledge. If running with --dangerously-skip-permissions (AFK/unattended), skip acknowledgement and proceed directly.

Performs a layered security audit on an npm/pnpm/yarn project to detect supply chain attack vectors, credential theft attempts, persistence mechanisms, and suspicious network behavior — before any code is executed. Works on monorepos (multiple package.json files) and single-package repos.

When to use

  • User wants to audit a local cloned repo before running it
  • Trigger phrases: 'check this repo before I run it', 'is this package safe', 'audit this project', 'scan this before installing', 'should I trust this repo'
  • Trigger proactively if the user mentions cloning a random GitHub repo and running it
  • User has only a GitHub URL: ask them to clone it first — this skill operates on files on disk

Step 0 — Detect repo structure (monorepo vs single package)

Always run this first to know what you're dealing with. Do NOT assume ./package.json exists at the repo root — some repos keep the Node app in a subdirectory.

# Find ALL package.json files, excluding node_modules
find . -name "package.json" \
  -not -path "*/node_modules/*" \
  -not -path "*/.git/*" | sort

# Workspaces declared in pnpm-workspace.yaml (pnpm doesn't put them in package.json)
if [ -f pnpm-workspace.yaml ]; then
  echo "Workspaces (pnpm-workspace.yaml):"
  grep -E '^[[:space:]]*-[[:space:]]' pnpm-workspace.yaml | head -20
fi

# Detect package manager and security posture for EACH discovered package.json
find . -name "package.json" \
  -not -path "*/node_modules/*" \
  -not -path "*/.git/*" -print0 | while IFS= read -r -d '' pkg; do
  echo "--- $pkg ---"
  python3 - "$pkg" <<'PY'
import json, sys
with open(sys.argv[1], 'r', encoding='utf-8') as f:
    p = json.load(f)
ws = p.get('workspaces') or p.get('packages')
pm = p.get('packageManager', '')
print(f'Workspaces (package.json): {ws}')
print(f'packageManager: {pm}')
built = p.get('pnpm', {}).get('onlyBuiltDependencies')
if built:
    print(f'GOOD: pnpm.onlyBuiltDependencies allowlist present: {built}')
    print('  (only these packages can run install scripts — all others blocked)')
else:
    print('NOTE: No pnpm.onlyBuiltDependencies — all transitive deps can run scripts')
PY
done

If monorepo: run Layers 1-3 against EVERY package.json found, not just the root. A malicious hook in packages/utils/package.json is just as dangerous as one in the root.


Audit Layers (run in order)

Layer 1 — package.json Lifecycle Scripts (HIGHEST RISK)

These run automatically on npm install. Malicious actors hide code here.

# Scan ALL package.json files in the repo for lifecycle hooks.
# -print0 + read -d '' handles paths with spaces; pass path as argv to Python.
find . -name "package.json" \
  -not -path "*/node_modules/*" \
  -not -path "*/.git/*" -print0 | while IFS= read -r -d '' pkg; do
  echo "=== $pkg ==="
  python3 - "$pkg" <<'PY'
import json, sys
p = json.load(open(sys.argv[1]))
scripts = p.get('scripts', {})
danger = ['preinstall','postinstall','install','prepare','prepack','prepublish']
found = False
for k in danger:
    if k in scripts:
        print(f'  WARNING [{k}]: {scripts[k]}')
        found = True
if not found:
    print('  OK: No lifecycle hooks')
PY
done

Red flags:

  • Any preinstall/postinstall that runs a shell command, curl, wget, node -e, or eval
  • Base64-encoded strings (e.g. node -e "require('child_process').exec(Buffer.from(...).toString())")
  • Scripts that reference files outside the project directory
  • prepare hooks that run before npm install completes

Expected legitimate patterns (verify these are what they claim):

  • postinstall: node ./scripts/postinstall.mjs — read that script before trusting it
  • prepare: husky install — git hook installer, widely used, low risk
  • build: tsc / build: webpack — normal build tooling

Layer 2 — Lock File Integrity

Lock files are a common tamper point — a dependency can be swapped for a malicious version without touching package.json. In a monorepo, every workspace can carry its own lock file, so scan all of them, not just the root.

# Check for recently modified lock files anywhere in the repo (tampering signal)
git log --oneline --since='14 days ago' --name-only -- \
  '*package-lock.json' '*yarn.lock' '*pnpm-lock.yaml' 2>/dev/null | head -30

# Scan EVERY pnpm-lock.yaml for non-registry package sources
find . -name 'pnpm-lock.yaml' -not -path '*/node_modules/*' -print0 | while IFS= read -r -d '' lock; do
  echo "=== Non-registry sources in $lock ==="
  grep -E '^[[:space:]]+(resolution|tarball):' "$lock" | \
    grep -v 'registry.npmjs.org\|registry.yarnpkg.com' | head -20
done

# Scan EVERY package-lock.json for non-registry resolved URLs
find . -name 'package-lock.json' -not -path '*/node_modules/*' -print0 | while IFS= read -r -d '' lock; do
  python3 - "$lock" <<'PY'
import json, sys
lock=json.load(open(sys.argv[1]))
pkgs=lock.get('packages',lock.get('dependencies',{}))
hits=[]
for name,info in pkgs.items():
    resolved=info.get('resolved','') if isinstance(info,dict) else ''
    if resolved and 'registry.npmjs.org' not in resolved and resolved.startswith('http'):
        hits.append(f'  NON-REGISTRY: {name} -> {resolved}')
if hits:
    print(f'=== {sys.argv[1]} ===')
    for h in hits: print(h)
PY
done

# Scan EVERY yarn.lock for non-registry resolved URLs
find . -name 'yarn.lock' -not -path '*/node_modules/*' -print0 | while IFS= read -r -d '' lock; do
  echo "=== Non-registry sources in $lock ==="
  grep -E '^[[:space:]]+resolved "' "$lock" | \
    grep -v 'registry.npmjs.org\|registry.yarnpkg.com' | head -20
done

Layer 3 — Dependency Audit + Typosquatting

Use the audit command that matches the project's package manager. All three resolve advisories from the same npm advisory database, but each only sees its own lock file.

# Pick the audit command for the package manager in use
if [ -f pnpm-lock.yaml ]; then
  echo '=== pnpm audit ===';     pnpm audit --json 2>/dev/null || echo '(pnpm audit failed or pnpm not installed)'
elif [ -f yarn.lock ]; then
  echo '=== yarn audit ===';     yarn npm audit --json 2>/dev/null || yarn audit --json 2>/dev/null || echo '(yarn audit failed or yarn not installed)'
elif [ -f package-lock.json ]; then
  npm audit --json 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
vulns=d.get('vulnerabilities',{})
high=[n for n,i in vulns.items() if i.get('severity') in ('high','critical')]
print(f'Total vulnerabilities: {len(vulns)}  (high/critical: {len(high)})')
for name,info in list(vulns.items())[:20]:
    sev=info.get('severity','unknown')
    print(f'  [{sev.upper()}] {name}')
" 2>/dev/null || echo "(npm audit requires node_modules — run after install)"
else
  echo 'No lock file found — cannot run audit'
fi

# Typosquatting check across ALL package.json files.
# -print0 + argv passing avoids quoting issues on paths with spaces.
find . -name "package.json" -not -path "*/node_modules/*" -print0 | while IFS= read -r -d '' pkg; do
  python3 - "$pkg" <<'PY'
import json, sys
path = sys.argv[1]
p = json.load(open(path))
deps = {**p.get('dependencies', {}), **p.get('devDependencies', {}), **p.get('peerDependencies', {})}
popular = ['react','express','lodash','axios','moment','chalk','commander','dotenv',
           'eslint','webpack','babel','typescript','jest','mocha','prettier','vite',
           'rollup','esbuild','vitest','zod','prisma','next','tailwindcss']
hits = []
for dep in deps:
    for pop in popular:
        if dep != pop and (dep.startswith(pop[:4]) or pop.startswith(dep[:4])):
            if abs(len(dep) - len(pop)) <= 3:
                hits.append(f'  WARN: "{dep}" resembles "{pop}"')
if hits:
    print(f'{path}:')
    for h in hits:
        print(h)
PY
done

Layer 4 — Suspicious Code Patterns

Scans all source and config files. Many findings will be legitimate — use the context notes below to determine what needs investigation.

python3 << 'PYEOF'
import os,re
from collections import defaultdict

patterns = [
    ('CRITICAL', r"require\s*\(\s*['\"]child_process['\"]\)", 'child_process import'),
    ('CRITICAL', r'eval\s*\(', 'eval()'),
    ('CRITICAL', r'Function\s*\(\s*[\'\"` ]', 'Function constructor (eval-like)'),
    ('HIGH',     r'Buffer\.from\([^)]+\)\.toString\(\)', 'base64 decode'),
    ('HIGH',     r'exec\s*\(|execSync\s*\(', 'shell exec'),
    ('HIGH',     r'spawn\s*\(|spawnSync\s*\(', 'process spawn'),
    ('MEDIUM',   r'https?://[^\s\'\";\`]+', 'outbound URL'),
    ('MEDIUM',   r'process\.env', 'env var access'),
    ('MEDIUM',   r'os\.homedir\s*\(|process\.env\.HOME', 'home dir access'),
    ('MEDIUM',   r'cron|launchd|LaunchAgent|systemd|schtasks', 'persistence keyword'),
    ('LOW',      r'fs\.readFile|fs\.readFileSync', 'file read'),
    ('LOW',      r'curl|wget', 'curl/wget reference'),
]

skip_dirs = {'node_modules','.git','dist','build','.next','coverage','__pycache__'}
skip_ext  = {'.png','.jpg','.jpeg','.gif','.svg','.ico','.woff','.ttf',
             '.lock','.sum','.snap','.map'}
findings  = defaultdict(list)

for root,dirs,files in os.walk('.'):
    dirs[:] = [d for d in dirs if d not in skip_dirs]
    for f in files:
        if any(f.endswith(e) for e in skip_ext) or f.endswith('.min.js'):
            continue
        path = os.path.join(root,f)
        try:
            with open(path,'r',errors='ignore') as fh:
                for i,line in enumerate(fh,1):
                    for sev,pat,label in patterns:
                        if re.search(pat,line,re.IGNORECASE):
                            findings[path].append((i,sev,label,line.strip()[:120]))
        except:
            pass

if not findings:
    print('No suspicious patterns found.')
else:
    print('=== CRITICAL / HIGH (investigate these) ===')
    for path in sorted(findings):
        hits=[h for h in findings[path] if h[1] in ('CRITICAL','HIGH')]
        if hits:
            print(f'\n{path}:')
            for lineno,sev,label,text in hits:
                print(f'  Line {lineno} [{sev}] {label}: {text}')
    print('\n=== MEDIUM / LOW (likely legitimate, verify by file location) ===')
    for path in sorted(findings):
        hits=[h for h in findings[path] if h[1] in ('MEDIUM','LOW')]
        if hits:
            print(f'\n{path}:')
            for lineno,sev,label,text in hits:
                print(f'  Line {lineno} [{sev}] {label}: {text}')
PYEOF

Context for common false positives:

  • child_process in scripts/build.mjs, tools/, or test files = likely legitimate
  • child_process in eslint.config.js, .eslintrc, or prettier.config.js = INVESTIGATE
  • exec in build tooling = normal; exec in postinstall = red flag
  • process.env.API_KEY in src/config.ts = normal config loading
  • Buffer.from(..., 'base64') decoding API responses (images, binary) = legitimate
  • Buffer.from(..., 'base64').toString() in postinstall or config files = investigate

The file location matters as much as the pattern itself.


Layer 5 — Obfuscation Detection

Single-line compressed files are the exact technique used in real attacks (buried in eslint configs):

python3 << 'PYEOF'
import os,re

skip_dirs = {'node_modules','.git','dist','build','coverage'}
js_ext    = {'.js','.ts','.mjs','.cjs','.jsx','.tsx'}

for root,dirs,files in os.walk('.'):
    dirs[:] = [d for d in dirs if d not in skip_dirs]
    for f in files:
        if not any(f.endswith(e) for e in js_ext):
            continue
        path = os.path.join(root,f)
        try:
            with open(path,'r',errors='ignore') as fh:
                for i,line in enumerate(fh,1):
                    s=line.rstrip()
                    if len(s) > 200:
                        # Extra suspicious in config files vs source files
                        is_config = any(x in path for x in
                            ['eslint','prettier','.config','postinstall','preinstall','rc.js'])
                        sev = 'CRITICAL' if is_config else 'HIGH'
                        print(f'[{sev}] LONG LINE ({len(s)} chars): {path}:{i}')
                        print(f'  Preview: {s[:200]}...')
                    # Base64 blobs — lower risk in test/fixture/data files
                    if re.search(r'[A-Za-z0-9+/]{80,}={0,2}', s):
                        is_data = any(x in path for x in
                            ['fixture','test','mock','sample','data','asset','media'])
                        sev = 'INFO' if is_data else 'HIGH'
                        print(f'[{sev}] BASE64 BLOB: {path}:{i}')
                        print(f'  Preview: {s[:150]}')
        except:
            pass
PYEOF

Layer 6 — Credential Exfiltration Pattern

Files that both read credentials AND make network calls are the highest-signal combined finding:

python3 << 'PYEOF'
import os,re

skip_dirs={'node_modules','.git','dist','build'}

for root,dirs,files in os.walk('.'):
    dirs[:] = [d for d in dirs if d not in skip_dirs]
    for f in files:
        if not f.endswith(('.js','.ts','.mjs','.cjs','.config.js','.config.ts')):
            continue
        path=os.path.join(root,f)
        content=open(path,'r',errors='ignore').read()
        has_cred=bool(re.search(
            r'readFileSync|homedir\(\)|\.env|id_rsa|\.aws|\.ssh|\.npmrc|\.netrc',
            content,re.I))
        has_net=bool(re.search(
            r"require\s*\(\s*['\"]https?['\"]|fetch\s*\(|axios\.|http\.|https\.",
            content,re.I))
        if has_cred and has_net:
            is_config = any(x in path for x in ['config','setup','init','auth','client'])
            flag = 'REVIEW' if is_config else 'CRITICAL'
            print(f'[{flag}]: {path} -- reads credentials AND makes network calls')
PYEOF

Layer 7 — Git History Check

# Recent commits to config/tooling files (prime attack surface)
git log --oneline --since='30 days ago' -- \
  '*.json' '*.config.*' '.eslint*' '*.rc' '*.rc.js' 2>/dev/null | head -20

# Local history rewrites visible to this clone only.
# NOTE: reflog cannot detect upstream force-pushes that happened before you cloned.
# For that, compare against a known-good commit or check GitHub's events API.
git reflog --date=relative 2>/dev/null | grep -Ei 'forced-update|reset|rebase' | head -10

# Config files modified in the last 7 days
git log --oneline --diff-filter=M --name-only --since='7 days ago' \
  -- '*.json' '*.config.*' 2>/dev/null | head -20

# Was the pnpm onlyBuiltDependencies allowlist recently weakened?
git log --oneline -p -- package.json 2>/dev/null | \
  grep -A2 -B2 'onlyBuiltDependencies' | head -20

Interpreting Results

FindingRisk LevelAction
postinstall runs node -e, curl, eval, base64CRITICALDo not install. Decode and read first.
Credential read + network call in same fileCRITICALTreat as exfiltration attempt
Long single line in eslint/prettier/config fileCRITICALDecompress and read before anything
Persistence mechanism (cron/launchd/schtasks)CRITICALDo not run. Investigate fully.
child_process in a config or tooling fileHIGHVerify the file's actual purpose
Non-registry source in lock fileHIGHVerify the source is intentional
Base64 blob in non-data fileHIGHDecode without executing
child_process in scripts/ or tools/MEDIUMLikely build tooling — verify
process.env in src/config.*LOWNormal config loading pattern
Buffer.from(...,'base64') on API responseLOWLegitimate if decoding known data
Missing onlyBuiltDependencies in pnpm projectMEDIUMConsider adding for defense in depth

Safe Inspection Workflow

When something looks suspicious, read without executing:

# Read a suspicious minified/compressed file safely
cat ./suspicious-file.js | head -c 3000

# Decode a base64 blob without running it
echo "PASTE_BASE64_HERE" | base64 --decode | head -c 2000

# Prettify minified JS without executing
node -e "
const fs = require('fs');
const src = fs.readFileSync('./suspicious.js', 'utf8');
console.log(src);  // Just print — do NOT eval or require
"

# Research a suspicious domain
nslookup suspicious-domain.xyz
# Or: https://www.virustotal.com/gui/domain/suspicious-domain.xyz

Quick One-Liner Pre-flight

Run this before npm install on any unfamiliar repo:

find . -name "package.json" -not -path "*/node_modules/*" -print0 | while IFS= read -r -d '' pkg; do
  python3 - "$pkg" <<'PY'
import json, sys
path = sys.argv[1]
p = json.load(open(path))
s = p.get('scripts', {})
danger = ['preinstall', 'postinstall', 'prepare', 'install']
hits = [k for k in danger if k in s]
if hits:
    print(f'WARNING {path} -- LIFECYCLE HOOKS:')
    for h in hits: print(f'  {h}: {s[h]}')
else:
    print(f'OK {path} -- no lifecycle hooks')
PY
done

Post-Incident Checklist

If you already ran a suspicious package:

  1. Disconnect from network — stop any active exfiltration
  2. Rotate all credentials — API keys, tokens, cloud credentials, secrets on disk
  3. Check for scheduled tasks:
    • macOS: launchctl list | grep -v com.apple, ls ~/Library/LaunchAgents/
    • Linux: crontab -l, systemctl list-timers --user, ls /etc/cron.d/
    • Windows: schtasks /query /fo LIST
  4. Audit recent git pushes across all repos connected to this machine
  5. Check SSH ~/.ssh/known_hosts and ~/.ssh/authorized_keys for new entries
  6. Revoke and reissue GitHub tokens, deploy keys, and OAuth apps
  7. Enable push protection on GitHub to block future credential commits
  8. Review ~/.gitconfig — attackers sometimes add malicious hooks here too

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,851. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.