e9bc1d0626
Closes the remaining gaps vs alirezarezvani/claude-skills across trust, content types, discoverability, and community. Security (trust signal + useful): - scripts/skill-audit.mjs scans skills/*/SKILL.md + each skill's scripts/ for prompt injection, exfiltration, dynamic code exec, destructive shell, secrets, and hidden text. HIGH fails CI (.github/workflows/skill-audit.yml) + a badge. - New skill-security-auditor skill teaches the same review (production tier). Content types: - output-styles/ — 4 personas (Startup CTO, Growth Marketer, Solo Founder, Product Leader) as Claude Code output styles; --agent claude installs them too. - ORCHESTRATION.md — Skill Chain / Multi-Agent Handoff / Domain Deep-Dive / Solo Sprint patterns. Discoverability: - scripts/build-docs.mjs generates a server-rendered, SEO-indexable web/catalog.html of all skills (built in the Pages deploy; gitignored). Linked from README + playground. Community: - ROADMAP.md (now/next/later + good-first-issues). README badges/sections, TIERS (47 production), CHANGELOG, package.json files, and exports/web index all updated. SkillCheck + security audit + exports verified. Claude-Session: https://claude.ai/code/session_016JWn5jRD5tcEFKrubjQ6Px Co-authored-by: Claude <noreply@anthropic.com>
131 lines
7.5 KiB
JavaScript
131 lines
7.5 KiB
JavaScript
#!/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. ✓');
|
||
}
|