#!/usr/bin/env node // Skill Security Auditor — scans installable skill content (skills/*/SKILL.md and // each skill's scripts/) for patterns that could harm someone who installs them: // prompt injection, data exfiltration, dynamic code execution, destructive shell, // hardcoded secrets, and hidden/obfuscated text. // // Only HIGH-severity findings fail the build; medium/low are advisory. This keeps // it useful without drowning legitimate skills in false positives. // // Usage: // node scripts/skill-audit.mjs # audit all skills // node scripts/skill-audit.mjs --json # machine-readable // node scripts/skill-audit.mjs --all # also fail on medium findings // // No dependencies. import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); const skillsDir = join(root, 'skills'); const args = process.argv.slice(2); const asJson = args.includes('--json'); const failOnMedium = args.includes('--all'); // severity: high (fail), medium, low. Each rule: {id, severity, re, why} const RULES = [ // ── Prompt injection aimed at the model ────────────────────────────────── { id: 'inject.ignore', severity: 'high', why: 'Tries to override the model\'s prior/system instructions.', re: /\b(ignore|disregard|forget)\b[^.\n]{0,40}\b(previous|prior|above|all|earlier|system)\b[^.\n]{0,20}\b(instructions?|prompts?|rules?|guidelines?)/i }, { id: 'inject.devmode', severity: 'high', why: 'Jailbreak framing (developer mode / DAN / no restrictions).', re: /\b(developer mode|do anything now|\bDAN\b|jailbreak|no (restrictions|guardrails|filters)|without (any )?(restrictions|limitations))\b/i }, { id: 'inject.reveal', severity: 'high', why: 'Tries to extract the system prompt / hidden instructions.', re: /\b(reveal|print|show|repeat|output)\b[^.\n]{0,30}\b(system prompt|your (instructions|system message|initial prompt)|hidden (instructions|prompt))/i }, { id: 'inject.persona', severity: 'medium', why: 'Forces an unconstrained persona override.', re: /\byou are now\b[^.\n]{0,40}\b(unrestricted|unfiltered|amoral|evil|no rules)\b/i }, // ── Data exfiltration ──────────────────────────────────────────────────── { id: 'exfil.send', severity: 'high', why: 'Instructs sending user/conversation data to an external endpoint.', re: /\b(send|post|upload|transmit|exfiltrate|forward)\b[^.\n]{0,40}\b(to )?(https?:\/\/|webhook|api\.|endpoint|server)\b[^.\n]{0,40}\b(conversation|messages?|data|credentials?|keys?|tokens?|history)/i }, { id: 'exfil.beacon', severity: 'medium', why: 'Network call to a hardcoded external URL inside content.', re: /\b(curl|wget|fetch\(|requests\.(get|post)|urllib|http\.client)\b[^.\n]{0,60}https?:\/\/(?!localhost|127\.0\.0\.1|\[|[a-z0-9.-]*example\.(com|org))/i }, // ── Code / command execution ───────────────────────────────────────────── { id: 'exec.dynamic', severity: 'medium', why: 'Executes dynamically-built code/commands.', re: /\b(eval|exec)\s*\(|\bos\.system\s*\(|subprocess\.(run|call|Popen)\s*\(|child_process|\bFunction\s*\(\s*['"`]/ }, { id: 'exec.destructive', severity: 'high', why: 'Destructive shell command.', re: /\brm\s+-rf\s+(\/|~|\$HOME|\*)|\b(mkfs|dd\s+if=)|\b:\(\)\s*\{\s*:\|:&\s*\}|\bchmod\s+-R?\s*777\s+\// }, // ── Credentials / secrets ──────────────────────────────────────────────── { id: 'secret.aws', severity: 'high', why: 'Looks like a hardcoded AWS access key.', re: /\bAKIA[0-9A-Z]{16}\b/ }, { id: 'secret.private-key', severity: 'high', why: 'Embedded private key.', re: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ }, { id: 'secret.harvest', severity: 'medium', why: 'Asks the user/model to hand over secrets.', re: /\b(send|share|paste|provide|enter)\b[^.\n]{0,30}\b(your )?(api[_ ]?key|password|secret|access token|ssh key|private key|seed phrase)\b/i }, // ── Obfuscation / hidden text ──────────────────────────────────────────── { id: 'hidden.zerowidth', severity: 'high', why: 'Contains zero-width / invisible Unicode (can hide instructions).', re: /[​-‏‪-‮⁠-⁤]/ }, { id: 'hidden.base64blob', severity: 'medium', why: 'Long base64 blob (possible hidden payload).', re: /\b[A-Za-z0-9+/]{220,}={0,2}\b/ }, ]; function auditText(rel, text, findings) { const lines = text.split('\n'); for (const rule of RULES) { // search line-by-line so we can report a location and a snippet for (let i = 0; i < lines.length; i++) { const m = lines[i].match(rule.re); if (m) { findings.push({ file: rel, line: i + 1, id: rule.id, severity: rule.severity, why: rule.why, snippet: lines[i].trim().slice(0, 120) }); break; // one hit per rule per file is enough } } // zero-width can sit anywhere incl. between lines — also test whole text if (rule.id === 'hidden.zerowidth' && !findings.some((f) => f.file === rel && f.id === rule.id) && rule.re.test(text)) { findings.push({ file: rel, line: 0, id: rule.id, severity: rule.severity, why: rule.why, snippet: '(invisible characters)' }); } } } function walk(dir, exts, out) { for (const e of readdirSync(dir)) { const p = join(dir, e); if (statSync(p).isDirectory()) walk(p, exts, out); else if (exts.some((x) => p.endsWith(x))) out.push(p); } } // Skills whose job is to *document* attack patterns (so they legitimately contain // the phrases the rules look for). Audited by humans, skipped by the scanner. const ALLOWLIST = new Set(['skill-security-auditor']); const findings = []; if (existsSync(skillsDir)) { for (const name of readdirSync(skillsDir)) { if (ALLOWLIST.has(name)) continue; const sdir = join(skillsDir, name); if (!statSync(sdir).isDirectory()) continue; const files = []; const skillMd = join(sdir, 'SKILL.md'); if (existsSync(skillMd)) files.push(skillMd); const scripts = join(sdir, 'scripts'); if (existsSync(scripts)) walk(scripts, ['.py', '.mjs', '.js', '.sh'], files); for (const f of files) auditText(relative(root, f), readFileSync(f, 'utf8'), findings); } } const counts = findings.reduce((a, f) => ((a[f.severity] = (a[f.severity] || 0) + 1), a), {}); const high = counts.high || 0, medium = counts.medium || 0, low = counts.low || 0; if (asJson) { console.log(JSON.stringify({ scanned: 'skills/**', high, medium, low, findings }, null, 2)); } else { const icon = { high: '🔴', medium: '🟠', low: '🟡' }; for (const f of findings.sort((a, b) => (a.severity < b.severity ? -1 : 1))) { console.log(` ${icon[f.severity]} [${f.severity}] ${f.file}:${f.line} (${f.id}) — ${f.why}`); if (f.snippet) console.log(` ↳ ${f.snippet}`); } console.log(`\nSkill Security Audit — ${high} high · ${medium} medium · ${low} low across skills/**`); } const failed = high > 0 || (failOnMedium && medium > 0); if (failed) { if (!asJson) console.log('FAILED — review the findings above. (False positive? Tune scripts/skill-audit.mjs.)'); process.exit(1); } else if (!asJson) { console.log('No high-severity issues found. ✓'); }