skill-security-scanner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,660 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import yaml from 'js-yaml';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { execSync } from 'child_process';
9
+
10
+ // ──────────────────────────────────────────────
11
+ // Types
12
+ // ──────────────────────────────────────────────
13
+ type RiskLevel = 'low' | 'med' | 'high';
14
+
15
+ interface Finding {
16
+ level: RiskLevel;
17
+ message: string;
18
+ }
19
+
20
+ interface ScanReport {
21
+ skill: string;
22
+ score: RiskLevel;
23
+ findings: Finding[];
24
+ badge: string;
25
+ upgrade: string;
26
+ }
27
+
28
+ interface Frontmatter {
29
+ name?: string;
30
+ description?: string;
31
+ version?: string;
32
+ permissions?: string[];
33
+ requires?: {
34
+ env?: string[];
35
+ config?: string[];
36
+ bin?: string[];
37
+ };
38
+ 'command-dispatch'?: Record<string, string>;
39
+ }
40
+
41
+ type LintSeverity = 'error' | 'warn';
42
+
43
+ interface LintIssue {
44
+ severity: LintSeverity;
45
+ field: string;
46
+ message: string;
47
+ }
48
+
49
+ interface LintResult {
50
+ skill: string;
51
+ passed: boolean;
52
+ issues: LintIssue[];
53
+ }
54
+
55
+ // ──────────────────────────────────────────────
56
+ // Risk Checks
57
+ // ──────────────────────────────────────────────
58
+
59
+ const DANGEROUS_BINS = ['curl', 'wget', 'nc', 'ncat', 'ssh', 'scp', 'rsync', 'python', 'python3', 'bash', 'sh'];
60
+ const SECRET_PATTERNS = [
61
+ /api[_-]?key\s*[:=]\s*["']?[A-Za-z0-9_\-]{16,}/i,
62
+ /secret\s*[:=]\s*["']?[A-Za-z0-9_\-]{16,}/i,
63
+ /token\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}/i,
64
+ /password\s*[:=]\s*["']?[A-Za-z0-9@#$!%^&*_\-]{8,}/i,
65
+ /sk-[A-Za-z0-9]{32,}/, // OpenAI key
66
+ /ghp_[A-Za-z0-9]{36}/, // GitHub PAT
67
+ /AKIA[0-9A-Z]{16}/, // AWS Access Key
68
+ ];
69
+ const EXEC_PATTERNS = /\b(eval|exec|execSync|spawn|spawnSync|child_process)\s*\(/g;
70
+ const NETWORK_PATTERNS = /\b(fetch|axios|http\.get|http\.request|https\.get|https\.request|got\(|request\()\s*\(/g;
71
+
72
+ function checkPermissions(fm: Frontmatter): Finding[] {
73
+ const findings: Finding[] = [];
74
+ const perms = fm.permissions ?? [];
75
+
76
+ if (perms.some(p => p.includes('file_system:*') || p === 'file_system:write')) {
77
+ findings.push({ level: 'med', message: 'Broad file_system write permission — verify scope is intentional' });
78
+ }
79
+ if (perms.some(p => p.includes('network:*') || p === 'network:outbound')) {
80
+ findings.push({ level: 'med', message: 'Outbound network permission detected — review data exfiltration risk' });
81
+ }
82
+ if (perms.some(p => p.includes('shell:execute') || p.includes('process:spawn'))) {
83
+ findings.push({ level: 'high', message: 'Shell/process execution permission — high privilege escalation risk' });
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ function checkDangerousBins(fm: Frontmatter): Finding[] {
89
+ const bins = fm.requires?.bin ?? [];
90
+ return bins
91
+ .filter(b => DANGEROUS_BINS.includes(b.toLowerCase()))
92
+ .map(b => ({ level: 'high' as RiskLevel, message: `Requires dangerous binary: ${b} (network/exfil risk)` }));
93
+ }
94
+
95
+ function checkExposedEnv(fm: Frontmatter): Finding[] {
96
+ const envVars = fm.requires?.env ?? [];
97
+ const sensitivePatterns = /key|secret|token|password|credential|api/i;
98
+ return envVars
99
+ .filter(e => sensitivePatterns.test(e))
100
+ .map(e => ({ level: 'med' as RiskLevel, message: `Sensitive env var required: ${e}` }));
101
+ }
102
+
103
+ function collectSourceFiles(dir: string): string[] {
104
+ const exts = ['.ts', '.js', '.py', '.sh', '.bash'];
105
+ const results: string[] = [];
106
+
107
+ function walk(current: string) {
108
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
109
+ const full = path.join(current, entry.name);
110
+ if (entry.isDirectory() && !['node_modules', '.git', 'dist'].includes(entry.name)) {
111
+ walk(full);
112
+ } else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
113
+ results.push(full);
114
+ }
115
+ }
116
+ }
117
+
118
+ walk(dir);
119
+ return results;
120
+ }
121
+
122
+ function checkCodeRisks(dir: string): Finding[] {
123
+ const findings: Finding[] = [];
124
+ const files = collectSourceFiles(dir);
125
+
126
+ for (const file of files) {
127
+ const content = fs.readFileSync(file, 'utf8');
128
+ const rel = path.relative(dir, file);
129
+
130
+ if (EXEC_PATTERNS.test(content)) {
131
+ findings.push({ level: 'high', message: `eval/exec found in ${rel} — remote code execution risk` });
132
+ }
133
+ if (NETWORK_PATTERNS.test(content)) {
134
+ findings.push({ level: 'med', message: `Network call found in ${rel} — verify destination is safe` });
135
+ }
136
+ for (const pattern of SECRET_PATTERNS) {
137
+ if (pattern.test(content)) {
138
+ findings.push({ level: 'high', message: `Possible hardcoded secret in ${rel}` });
139
+ break;
140
+ }
141
+ }
142
+
143
+ // Reset stateful regexes
144
+ EXEC_PATTERNS.lastIndex = 0;
145
+ NETWORK_PATTERNS.lastIndex = 0;
146
+ }
147
+
148
+ return findings;
149
+ }
150
+
151
+ function checkDependencyVulns(dir: string, skipAudit = false): Finding[] {
152
+ const findings: Finding[] = [];
153
+ const pkgPath = path.join(dir, 'package.json');
154
+ if (!fs.existsSync(pkgPath) || skipAudit) return findings;
155
+
156
+ try {
157
+ const auditOutput = execSync('npm audit --json', { cwd: dir, timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
158
+ const audit = JSON.parse(auditOutput);
159
+ const vulnCount: number = audit?.metadata?.vulnerabilities?.total ?? 0;
160
+ const high: number = (audit?.metadata?.vulnerabilities?.high ?? 0) + (audit?.metadata?.vulnerabilities?.critical ?? 0);
161
+
162
+ if (high > 0) findings.push({ level: 'high', message: `${high} high/critical npm vulnerabilities found — run npm audit fix` });
163
+ else if (vulnCount > 0) findings.push({ level: 'med', message: `${vulnCount} moderate npm vulnerabilities found` });
164
+ } catch {
165
+ // npm not available, no lock file, or timed out — skip silently
166
+ }
167
+
168
+ return findings;
169
+ }
170
+
171
+ // ──────────────────────────────────────────────
172
+ // Score Calculation
173
+ // ──────────────────────────────────────────────
174
+
175
+ function calculateScore(findings: Finding[]): RiskLevel {
176
+ if (findings.some(f => f.level === 'high')) return 'high';
177
+ if (findings.some(f => f.level === 'med')) return 'med';
178
+ return 'low';
179
+ }
180
+
181
+
182
+ // ──────────────────────────────────────────────
183
+ // Path Resolution
184
+ // ──────────────────────────────────────────────
185
+
186
+ /** Walk up from `startDir` looking for a directory named `folderName`. */
187
+ function findAncestorDir(startDir: string, folderName: string): string | null {
188
+ let current = startDir;
189
+ for (let i = 0; i < 8; i++) {
190
+ const candidate = path.join(current, folderName);
191
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
192
+ const parent = path.dirname(current);
193
+ if (parent === current) break;
194
+ current = parent;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Resolve the skill directory from user input.
201
+ * Priority:
202
+ * 1. Exact path (absolute or relative) → use as-is
203
+ * 2. Walk up looking for `.agent/skills/<input>` or `agent/skills/<input>`
204
+ * 3. Walk up looking for any `skills/<input>` folder
205
+ */
206
+ function resolveSkillDir(input: string): string | null {
207
+ const exact = path.resolve(input);
208
+ if (fs.existsSync(exact)) return exact;
209
+
210
+ const name = path.basename(input); // support "frontend-design" or "skills/frontend-design"
211
+ const agentFolders = ['.agent', 'agent', '_agent', '_agents'];
212
+
213
+ for (const agentFolder of agentFolders) {
214
+ const agentDir = findAncestorDir(process.cwd(), agentFolder);
215
+ if (!agentDir) continue;
216
+
217
+ const candidate = path.join(agentDir, 'skills', name);
218
+ if (fs.existsSync(candidate)) return candidate;
219
+
220
+ // also try the input as a sub-path inside agentDir
221
+ const subCandidate = path.join(agentDir, input);
222
+ if (fs.existsSync(subCandidate)) return subCandidate;
223
+ }
224
+
225
+ return null;
226
+ }
227
+
228
+ // ──────────────────────────────────────────────
229
+ // Shared scan helper
230
+ // ──────────────────────────────────────────────
231
+
232
+ interface SkillScanResult {
233
+ name: string;
234
+ dir: string;
235
+ score: RiskLevel;
236
+ findings: Finding[];
237
+ }
238
+
239
+ function scanSkill(skillDir: string, skipAudit = false): SkillScanResult {
240
+ const skillPath = path.join(skillDir, 'SKILL.md');
241
+ const content = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : '';
242
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/m);
243
+
244
+ let frontmatter: Frontmatter = {};
245
+ try {
246
+ frontmatter = (yaml.load(fmMatch?.[1] ?? '') ?? {}) as Frontmatter;
247
+ } catch {
248
+ // malformed YAML — treat as unparseable, flag it
249
+ }
250
+
251
+ const findings: Finding[] = [
252
+ ...checkPermissions(frontmatter),
253
+ ...checkDangerousBins(frontmatter),
254
+ ...checkExposedEnv(frontmatter),
255
+ ...checkCodeRisks(skillDir),
256
+ ...checkDependencyVulns(skillDir, skipAudit),
257
+ ];
258
+
259
+ if (!fmMatch) {
260
+ findings.push({ level: 'low', message: 'No YAML frontmatter found in SKILL.md' });
261
+ }
262
+
263
+ return {
264
+ name: frontmatter.name ?? path.basename(skillDir),
265
+ dir: skillDir,
266
+ score: calculateScore(findings),
267
+ findings,
268
+ };
269
+ }
270
+
271
+
272
+ /** Find all immediate subdirectories of `skillsDir` that contain a SKILL.md */
273
+ function discoverSkillDirs(skillsDir: string): string[] {
274
+ if (!fs.existsSync(skillsDir)) return [];
275
+ return fs.readdirSync(skillsDir, { withFileTypes: true })
276
+ .filter(e => e.isDirectory())
277
+ .map(e => path.join(skillsDir, e.name))
278
+ .filter(d => fs.existsSync(path.join(d, 'SKILL.md')));
279
+ }
280
+
281
+ // ──────────────────────────────────────────────
282
+ // Lint
283
+ // ──────────────────────────────────────────────
284
+
285
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/;
286
+ const PERMISSION_SCOPES = [
287
+ 'file_system:read', 'file_system:write', 'file_system:*',
288
+ 'network:inbound', 'network:outbound', 'network:*',
289
+ 'shell:execute', 'process:spawn', 'memory:read', 'memory:write',
290
+ ];
291
+
292
+ function lintSkill(skillDir: string): LintResult {
293
+ const skillPath = path.join(skillDir, 'SKILL.md');
294
+ const issues: LintIssue[] = [];
295
+ const skillName = path.basename(skillDir);
296
+
297
+ // ── File existence ──
298
+ if (!fs.existsSync(skillPath)) {
299
+ return { skill: skillName, passed: false, issues: [{ severity: 'error', field: 'SKILL.md', message: 'File not found' }] };
300
+ }
301
+
302
+ const content = fs.readFileSync(skillPath, 'utf8');
303
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/m);
304
+
305
+ if (!fmMatch) {
306
+ return { skill: skillName, passed: false, issues: [{ severity: 'error', field: 'frontmatter', message: 'No YAML frontmatter block found (expected --- ... ---)' }] };
307
+ }
308
+
309
+ let fm: Frontmatter = {};
310
+ try {
311
+ fm = (yaml.load(fmMatch[1]) ?? {}) as Frontmatter;
312
+ } catch (e: unknown) {
313
+ const msg = e instanceof Error ? e.message : String(e);
314
+ return { skill: skillName, passed: false, issues: [{ severity: 'error', field: 'frontmatter', message: `YAML parse error: ${msg}` }] };
315
+ }
316
+
317
+ // ── Required fields ──
318
+ if (!fm.name) {
319
+ issues.push({ severity: 'error', field: 'name', message: 'Missing required field' });
320
+ } else if (typeof fm.name !== 'string') {
321
+ issues.push({ severity: 'error', field: 'name', message: 'Must be a string' });
322
+ } else if (fm.name.trim().length < 3) {
323
+ issues.push({ severity: 'error', field: 'name', message: 'Too short (min 3 chars)' });
324
+ }
325
+
326
+ if (!fm.description) {
327
+ issues.push({ severity: 'error', field: 'description', message: 'Missing required field' });
328
+ } else if (typeof fm.description !== 'string') {
329
+ issues.push({ severity: 'error', field: 'description', message: 'Must be a string' });
330
+ } else if (fm.description.trim().length < 10) {
331
+ issues.push({ severity: 'warn', field: 'description', message: 'Very short description (min 10 chars recommended)' });
332
+ }
333
+
334
+ if (!fm.version) {
335
+ issues.push({ severity: 'error', field: 'version', message: 'Missing required field' });
336
+ } else if (!SEMVER_RE.test(String(fm.version))) {
337
+ issues.push({ severity: 'error', field: 'version', message: `"${fm.version}" is not valid semver (expected x.y.z)` });
338
+ }
339
+
340
+ // ── Optional field validation ──
341
+ if (fm.permissions !== undefined) {
342
+ if (!Array.isArray(fm.permissions)) {
343
+ issues.push({ severity: 'error', field: 'permissions', message: 'Must be an array of strings' });
344
+ } else {
345
+ fm.permissions.forEach((p, i) => {
346
+ if (typeof p !== 'string') {
347
+ issues.push({ severity: 'error', field: `permissions[${i}]`, message: 'Each permission must be a string' });
348
+ } else if (!PERMISSION_SCOPES.includes(p)) {
349
+ issues.push({ severity: 'warn', field: `permissions[${i}]`, message: `Unknown permission scope "${p}" — check OpenClaw docs` });
350
+ }
351
+ });
352
+ }
353
+ }
354
+
355
+ if (fm.requires !== undefined) {
356
+ if (typeof fm.requires !== 'object' || Array.isArray(fm.requires)) {
357
+ issues.push({ severity: 'error', field: 'requires', message: 'Must be an object with optional env/config/bin arrays' });
358
+ } else {
359
+ for (const key of ['env', 'config', 'bin'] as const) {
360
+ const val = fm.requires[key];
361
+ if (val !== undefined && !Array.isArray(val)) {
362
+ issues.push({ severity: 'error', field: `requires.${key}`, message: 'Must be an array of strings' });
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ // ── Description quality hints ──
369
+ const body = content.slice(content.indexOf('---', 3) + 3).trim();
370
+ if (body.length < 50) {
371
+ issues.push({ severity: 'warn', field: 'SKILL.md body', message: 'Skill body is very short — consider adding usage examples or detailed instructions' });
372
+ }
373
+
374
+ const errors = issues.filter(i => i.severity === 'error');
375
+ return { skill: fm.name ?? skillName, passed: errors.length === 0, issues };
376
+ }
377
+
378
+ // ──────────────────────────────────────────────
379
+ // CLI
380
+ // ──────────────────────────────────────────────
381
+
382
+ const program = new Command();
383
+
384
+ program
385
+ .name('skill-security-scanner')
386
+ .description('Static security scanner for OpenClaw skill directories')
387
+ .version('0.1.0');
388
+
389
+ // ── Command: scan (single skill) ──────────────
390
+
391
+ program
392
+ .command('scan [dir]', { isDefault: true })
393
+ .description('Scan a single skill directory (default command)')
394
+ .option('--json', 'Output results as JSON (for CI pipelines)')
395
+ .option('--badge', 'Print Markdown badge to stdout')
396
+ .action(async (dir: string | undefined, opts: { json?: boolean; badge?: boolean }) => {
397
+ if (!dir) {
398
+ console.error(chalk.red('❌ Missing skill name or path.'));
399
+ console.error(chalk.gray(' Usage: skill-security-scanner <name>'));
400
+ console.error(chalk.gray(' Example: skill-security-scanner frontend-design'));
401
+ console.error(chalk.gray(' Scan all: skill-security-scanner scan-all'));
402
+ process.exit(1);
403
+ }
404
+
405
+ const resolvedDir = resolveSkillDir(dir);
406
+
407
+ if (!resolvedDir) {
408
+ console.error(chalk.red(`❌ Skill not found: "${dir}"`));
409
+ console.error(chalk.gray(' Try: skill-security-scanner frontend-design'));
410
+ console.error(chalk.gray(' Or: skill-security-scanner .agent/skills/frontend-design'));
411
+ process.exit(1);
412
+ }
413
+
414
+ if (!fs.existsSync(path.join(resolvedDir, 'SKILL.md'))) {
415
+ console.error(chalk.red('❌ No SKILL.md found — is this an OpenClaw skill directory?'));
416
+ process.exit(1);
417
+ }
418
+
419
+ const spinner = ora('Scanning skill...').start();
420
+ const result = scanSkill(resolvedDir);
421
+ spinner.stop();
422
+
423
+ const BADGE_COLOR_MAP: Record<RiskLevel, string> = { low: 'brightgreen', med: 'yellow', high: 'red' };
424
+ const badgeLabel = result.score.toUpperCase();
425
+ const badge = `![Skill Security: ${badgeLabel}](https://img.shields.io/badge/Skill%20Security-${badgeLabel}-${BADGE_COLOR_MAP[result.score]}?style=flat-square&logo=shield)`;
426
+ const upgrade = 'Full dynamic analysis, GitHub Action & CI dashboards → skill-security.com (7-day free trial)';
427
+
428
+ const report: ScanReport = {
429
+ skill: result.name, score: result.score, findings: result.findings, badge,
430
+ upgrade: ''
431
+ };
432
+
433
+ if (opts.json) { console.log(JSON.stringify(report, null, 2)); return; }
434
+
435
+ const scoreColor = { low: chalk.green, med: chalk.yellow, high: chalk.red }[result.score];
436
+ const icon = { low: '✅', med: '⚠️', high: '🚨' }[result.score];
437
+ const printGroup = (level: RiskLevel, label: string, color: { bold: (s: string) => string } & ((s: string) => string)) => {
438
+ const group = result.findings.filter(f => f.level === level);
439
+ if (!group.length) return;
440
+ console.log(color.bold(`${label} (${group.length})`));
441
+ group.forEach(f => console.log(color(` • ${f.message}`)));
442
+ console.log('');
443
+ };
444
+
445
+ console.log('');
446
+ console.log(chalk.bold.white(`🔍 Skill Security Scanner — ${result.name}`));
447
+ console.log(chalk.gray('─'.repeat(50)));
448
+ console.log(`Risk Score: ${scoreColor(`${icon} ${result.score.toUpperCase()}`)}`);
449
+ console.log('');
450
+
451
+ if (!result.findings.length) {
452
+ console.log(chalk.green('✅ No issues found. Skill looks clean.'));
453
+ } else {
454
+ printGroup('high', '🚨 HIGH RISK', chalk.red);
455
+ printGroup('med', '⚠️ MEDIUM RISK', chalk.yellow);
456
+ printGroup('low', '💡 LOW RISK', chalk.gray);
457
+ }
458
+
459
+ if (opts.badge) { console.log(chalk.gray('Badge:\n')); console.log(badge); console.log(''); }
460
+ console.log(chalk.blueBright(`💡 ${upgrade}`));
461
+ console.log('');
462
+ if (result.score === 'high') process.exit(1);
463
+ });
464
+
465
+ // ── Command: lint ────────────────────────────
466
+
467
+ program
468
+ .command('lint [dir]')
469
+ .description('Validate SKILL.md frontmatter against the OpenClaw schema')
470
+ .option('--json', 'Output results as JSON')
471
+ .option('--strict', 'Treat warnings as errors (exit 1)')
472
+ .option('--all', 'Lint every skill in the project')
473
+ .action((dir: string | undefined, opts: { json?: boolean; strict?: boolean; all?: boolean }) => {
474
+ // ── lint --all shortcut ──
475
+ if (opts.all || !dir) {
476
+ const agentFolders = ['.agent', 'agent', '_agent', '_agents'];
477
+ let skillsDir: string | null = null;
478
+ for (const folder of agentFolders) {
479
+ const agentDir = findAncestorDir(process.cwd(), folder);
480
+ if (agentDir) {
481
+ const c = path.join(agentDir, 'skills');
482
+ if (fs.existsSync(c)) { skillsDir = c; break; }
483
+ }
484
+ }
485
+ if (!skillsDir) {
486
+ console.error(chalk.red('❌ No .agent/skills directory found.'));
487
+ process.exit(1);
488
+ }
489
+ const dirs = discoverSkillDirs(skillsDir);
490
+ const results = dirs.map(d => lintSkill(d));
491
+
492
+ if (opts.json) { console.log(JSON.stringify(results, null, 2)); }
493
+ else {
494
+ const passed = results.filter(r => r.passed && !(opts.strict && r.issues.some(i => i.severity === 'warn')));
495
+ const failed = results.filter(r => !r.passed || (opts.strict && r.issues.some(i => i.severity === 'warn')));
496
+ console.log('');
497
+ console.log(chalk.bold.white(`🔎 Lint — ${results.length} skills`));
498
+ console.log(chalk.gray('─'.repeat(60)));
499
+ for (const r of [...failed, ...passed]) {
500
+ const ok = !failed.includes(r);
501
+ console.log(ok ? chalk.green(` ✅ ${r.skill}`) : chalk.red(` ❌ ${r.skill}`));
502
+ r.issues.forEach(issue => {
503
+ const c = issue.severity === 'error' ? chalk.red : chalk.yellow;
504
+ console.log(c(` [${issue.severity.toUpperCase()}] ${issue.field}: ${issue.message}`));
505
+ });
506
+ }
507
+ console.log(chalk.gray('─'.repeat(60)));
508
+ console.log(` ${chalk.green(`✅ ${passed.length} passed`)} ${chalk.red(`❌ ${failed.length} failed`)}`);
509
+ console.log('');
510
+ }
511
+ const anyFailed = results.some(r => !r.passed || (opts.strict && r.issues.some(i => i.severity === 'warn')));
512
+ if (anyFailed) process.exit(1);
513
+ return;
514
+ }
515
+
516
+ // ── single skill ──
517
+ const resolvedDir = resolveSkillDir(dir);
518
+ if (!resolvedDir) {
519
+ console.error(chalk.red(`❌ Skill not found: "${dir}"`));
520
+ process.exit(1);
521
+ }
522
+
523
+ const result = lintSkill(resolvedDir);
524
+ const hasFail = !result.passed || (opts.strict && result.issues.some(i => i.severity === 'warn'));
525
+
526
+ if (opts.json) { console.log(JSON.stringify(result, null, 2)); }
527
+ else {
528
+ const errors = result.issues.filter(i => i.severity === 'error');
529
+ const warns = result.issues.filter(i => i.severity === 'warn');
530
+ const statusIcon = hasFail ? '❌' : '✅';
531
+ const statusText = hasFail ? chalk.red('FAIL') : chalk.green('PASS');
532
+
533
+ console.log('');
534
+ console.log(chalk.bold.white(`🔎 Lint — ${result.skill}`));
535
+ console.log(chalk.gray('─'.repeat(50)));
536
+ console.log(`Status: ${statusIcon} ${statusText}`);
537
+ console.log('');
538
+
539
+ if (!result.issues.length) {
540
+ console.log(chalk.green('✅ All fields valid. Skill looks well-formed.'));
541
+ } else {
542
+ if (errors.length) {
543
+ console.log(chalk.red.bold(`Errors (${errors.length})`));
544
+ errors.forEach(i => console.log(chalk.red(` • [${i.field}] ${i.message}`)));
545
+ console.log('');
546
+ }
547
+ if (warns.length) {
548
+ console.log(chalk.yellow.bold(`Warnings (${warns.length})`));
549
+ warns.forEach(i => console.log(chalk.yellow(` • [${i.field}] ${i.message}`)));
550
+ console.log('');
551
+ }
552
+ }
553
+ }
554
+
555
+ if (hasFail) process.exit(1);
556
+ });
557
+
558
+ // ── Command: scan-all ─────────────────────────
559
+
560
+ program
561
+ .command('scan-all')
562
+ .description('Scan every skill in the project and show a summary table')
563
+ .option('--json', 'Output results as JSON array')
564
+ .option('--fail-on <level>', 'Exit code 1 if any skill reaches this level (low|med|high)', 'high')
565
+ .option('--skip-audit', 'Skip npm audit (much faster for large projects)')
566
+ .action(async (opts: { json?: boolean; failOn: RiskLevel; skipAudit?: boolean }) => {
567
+ const agentFolders = ['.agent', 'agent', '_agent', '_agents'];
568
+ let skillsDir: string | null = null;
569
+
570
+ for (const folder of agentFolders) {
571
+ const agentDir = findAncestorDir(process.cwd(), folder);
572
+ if (agentDir) {
573
+ const candidate = path.join(agentDir, 'skills');
574
+ if (fs.existsSync(candidate)) { skillsDir = candidate; break; }
575
+ }
576
+ }
577
+
578
+ if (!skillsDir) {
579
+ console.error(chalk.red('❌ No .agent/skills directory found from the current location.'));
580
+ process.exit(1);
581
+ }
582
+
583
+ const skillDirs = discoverSkillDirs(skillsDir);
584
+
585
+ if (!skillDirs.length) {
586
+ console.error(chalk.yellow('⚠️ No skill directories with SKILL.md found.'));
587
+ process.exit(0);
588
+ }
589
+
590
+ // Auto-skip audit for large projects unless explicitly requested
591
+ const skipAudit = opts.skipAudit ?? skillDirs.length > 5;
592
+ const spinner = ora(`Scanning ${skillDirs.length} skills${skipAudit ? ' (audit skipped)' : ''}...`).start();
593
+ const results: SkillScanResult[] = skillDirs.map(d => scanSkill(d, skipAudit));
594
+ spinner.stop();
595
+
596
+ if (opts.json) {
597
+ console.log(JSON.stringify(results.map(r => ({
598
+ skill: r.name, score: r.score, findingCount: r.findings.length, findings: r.findings,
599
+ })), null, 2));
600
+ const hasFailure = results.some(r => r.score === opts.failOn || (opts.failOn === 'low' && true) || (opts.failOn === 'med' && r.score !== 'low'));
601
+ if (hasFailure) process.exit(1);
602
+ return;
603
+ }
604
+
605
+ // ── Summary table ──
606
+ const counts = { high: 0, med: 0, low: 0 };
607
+ results.forEach(r => counts[r.score]++);
608
+
609
+ const SCORE_COLOR = { low: chalk.green, med: chalk.yellow, high: chalk.red };
610
+ const SCORE_ICON = { low: '✅', med: '⚠️ ', high: '🚨' };
611
+ const FIND_COLOR = { low: chalk.gray, med: chalk.yellow, high: chalk.red };
612
+
613
+ // Sort: high first, then med, then low
614
+ const sorted = [...results].sort((a, b) => {
615
+ const order: Record<RiskLevel, number> = { high: 0, med: 1, low: 2 };
616
+ return order[a.score] - order[b.score];
617
+ });
618
+
619
+ console.log('');
620
+ console.log(chalk.bold.white(`🔍 Skill Security Scanner — Project Scan (${results.length} skills)`));
621
+ console.log(chalk.gray('─'.repeat(60)));
622
+
623
+ for (const r of sorted) {
624
+ const color = SCORE_COLOR[r.score];
625
+ const icon = SCORE_ICON[r.score];
626
+
627
+ if (!r.findings.length) {
628
+ console.log(` ${icon} ${color(r.score.toUpperCase().padEnd(5))} ${r.name}`);
629
+ } else {
630
+ // Header row for this skill
631
+ console.log(` ${icon} ${color(r.score.toUpperCase().padEnd(5))} ${chalk.bold(r.name)}`);
632
+
633
+ // Print findings grouped by severity
634
+ for (const level of ['high', 'med', 'low'] as RiskLevel[]) {
635
+ const group = r.findings.filter(f => f.level === level);
636
+ group.forEach(f => {
637
+ console.log(` ${FIND_COLOR[level](`→ [${f.level.toUpperCase()}] ${f.message}`)}`);
638
+ });
639
+ }
640
+ console.log('');
641
+ }
642
+ }
643
+
644
+ console.log(chalk.gray('─'.repeat(60)));
645
+ console.log(
646
+ ` ${chalk.red(`🚨 ${counts.high} HIGH`)} ` +
647
+ `${chalk.yellow(`⚠️ ${counts.med} MED`)} ` +
648
+ `${chalk.green(`✅ ${counts.low} LOW`)}`
649
+ );
650
+ console.log('');
651
+ // console.log(chalk.blueBright('Full dynamic analysis, GitHub Action & CI dashboards → skill-security.com (7-day free trial)'));
652
+ console.log('');
653
+
654
+ const LEVEL_ORDER: Record<RiskLevel, number> = { low: 0, med: 1, high: 2 };
655
+ const shouldFail = results.some(r => LEVEL_ORDER[r.score] >= LEVEL_ORDER[opts.failOn]);
656
+ if (shouldFail) process.exit(1);
657
+ });
658
+
659
+ program.parse();
660
+
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": [
6
+ "ES2022"
7
+ ],
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "resolveJsonModule": true
17
+ },
18
+ "include": [
19
+ "src/**/*"
20
+ ],
21
+ "exclude": [
22
+ "node_modules",
23
+ "dist"
24
+ ]
25
+ }