Security auditor, personas, orchestration, docs catalog & roadmap (#35)
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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
#!/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. ✓');
|
||||
}
|
||||
Reference in New Issue
Block a user