agent-security-scanner-mcp 3.10.2 → 3.11.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.
@@ -1,6 +1,6 @@
1
1
  // src/tools/scan-security.js
2
2
  import { z } from "zod";
3
- import { existsSync, readFileSync } from "fs";
3
+ import { existsSync, readFileSync, statSync } from "fs";
4
4
  import { dirname } from "path";
5
5
  import { detectLanguage, runAnalyzerAsync, generateFix, toSarif, getEngineMode, extractImports, isTestFile } from '../utils.js';
6
6
  import { deduplicateFindings } from '../dedup.js';
@@ -8,6 +8,8 @@ import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from
8
8
  import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
9
9
  import { discoverProjectContext } from './project-context.js';
10
10
 
11
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB - skip files larger than this to avoid timeouts
12
+
11
13
  export const scanSecuritySchema = {
12
14
  file_path: z.string().describe("Path to the file to scan"),
13
15
  output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
@@ -69,6 +71,26 @@ export async function scanSecurity({ file_path, output_format, verbosity, engine
69
71
  };
70
72
  }
71
73
 
74
+ // Check file size to avoid timeouts on very large files
75
+ try {
76
+ const stats = statSync(file_path);
77
+ if (stats.size > MAX_FILE_SIZE) {
78
+ return {
79
+ content: [{
80
+ type: "text",
81
+ text: JSON.stringify({
82
+ file: file_path,
83
+ message: `File too large (${(stats.size / 1024).toFixed(0)}KB). Skipping to avoid timeout. Max size: ${MAX_FILE_SIZE / 1024}KB.`,
84
+ issues_count: 0,
85
+ skipped: true
86
+ })
87
+ }]
88
+ };
89
+ }
90
+ } catch (err) {
91
+ // If stat fails, continue anyway
92
+ }
93
+
72
94
  // Load project configuration
73
95
  const config = loadConfig(file_path);
74
96
 
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Prompt Security Scanner for ClawHub SKILL.md Files
3
+ *
4
+ * Scans prompt-based agent skills for:
5
+ * - Prompt injection attacks
6
+ * - Jailbreak attempts
7
+ * - Data exfiltration patterns
8
+ * - Privilege escalation
9
+ * - Social engineering
10
+ * - Misleading descriptions
11
+ *
12
+ * This scanner addresses the finding that 94% of ClawHub skills
13
+ * are prompt templates, not executable code.
14
+ */
15
+
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+
19
+ /**
20
+ * Prompt injection detection patterns
21
+ * Categorized by attack type and severity
22
+ */
23
+ const PROMPT_PATTERNS = {
24
+ // CRITICAL: Direct prompt injection attempts
25
+ promptInjection: {
26
+ severity: 'CRITICAL',
27
+ patterns: [
28
+ {
29
+ name: 'Ignore Previous Instructions',
30
+ regex: /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directives?|commands?|prompts?)/gi,
31
+ description: 'Attempts to override system instructions',
32
+ cwe: 'CWE-74'
33
+ },
34
+ {
35
+ name: 'System Override',
36
+ regex: /(override|disregard|forget|reset)\s+(system|instructions?|rules?|constraints?)/gi,
37
+ description: 'Tries to bypass system constraints',
38
+ cwe: 'CWE-74'
39
+ },
40
+ {
41
+ name: 'Role Manipulation',
42
+ regex: /(you\s+are\s+now|from\s+now\s+on.*you\s+are|act\s+as\s+if\s+you\s+are)\s+(a\s+)?(jailbroken|unrestricted|dan|sudo|admin|root)/gi,
43
+ description: 'Attempts to change AI role to bypass restrictions',
44
+ cwe: 'CWE-269'
45
+ },
46
+ {
47
+ name: 'DAN (Do Anything Now) Jailbreak',
48
+ regex: /(do\s+anything\s+now|DAN\s+mode|enable\s+developer\s+mode)/gi,
49
+ description: 'Known jailbreak technique',
50
+ cwe: 'CWE-269'
51
+ }
52
+ ]
53
+ },
54
+
55
+ // HIGH: Data exfiltration and privacy violations
56
+ dataExfiltration: {
57
+ severity: 'HIGH',
58
+ patterns: [
59
+ {
60
+ name: 'Credential Harvesting',
61
+ regex: /(send|transmit|provide|give\s+me)\s+(your|the|all)?\s*(api[\s_-]?keys?|passwords?|tokens?|credentials?|secrets?)/gi,
62
+ description: 'Attempts to extract sensitive credentials',
63
+ cwe: 'CWE-200'
64
+ },
65
+ {
66
+ name: 'PII Exfiltration',
67
+ regex: /(extract|collect|send|reveal)\s+(all\s+)?(user|personal)\s+(data|information|details|emails?|addresses?)/gi,
68
+ description: 'Tries to access user personal information',
69
+ cwe: 'CWE-359'
70
+ },
71
+ {
72
+ name: 'Conversation History Access',
73
+ regex: /(show|display|reveal|print)\s+(previous|all|entire)\s+(conversation|chat|history|messages?)/gi,
74
+ description: 'Attempts to access conversation history',
75
+ cwe: 'CWE-200'
76
+ },
77
+ {
78
+ name: 'External Data Transmission',
79
+ regex: /(send|post|upload|transmit)\s+(to|at)?\s*(https?:\/\/|www\.|pastebin|discord\.gg)/gi,
80
+ description: 'Sends data to external servers',
81
+ cwe: 'CWE-359'
82
+ }
83
+ ]
84
+ },
85
+
86
+ // HIGH: Jailbreak and constraint bypass
87
+ jailbreak: {
88
+ severity: 'HIGH',
89
+ patterns: [
90
+ {
91
+ name: 'Pretend Mode',
92
+ regex: /pretend\s+(you\s+are|to\s+be)\s+(unfiltered|unrestricted|without\s+limitations?)/gi,
93
+ description: 'Jailbreak via roleplay',
94
+ cwe: 'CWE-863'
95
+ },
96
+ {
97
+ name: 'Hypothetical Scenario',
98
+ regex: /in\s+a\s+hypothetical\s+(world|scenario|universe)\s+where\s+(you\s+)?(have\s+no|are\s+not\s+bound\s+by|ignore)/gi,
99
+ description: 'Bypasses restrictions via hypotheticals',
100
+ cwe: 'CWE-863'
101
+ },
102
+ {
103
+ name: 'Developer Mode',
104
+ regex: /(enable|activate|enter)\s+(developer|debug|god|admin)\s+mode/gi,
105
+ description: 'Attempts to activate privileged mode',
106
+ cwe: 'CWE-269'
107
+ },
108
+ {
109
+ name: 'Emoji/Token Obfuscation',
110
+ regex: /\[.*system.*\].*\[.*jailbreak.*\]|🔓|🔐.*bypass/gi,
111
+ description: 'Uses symbols to obfuscate jailbreak',
112
+ cwe: 'CWE-74'
113
+ }
114
+ ]
115
+ },
116
+
117
+ // MEDIUM: Social engineering and deception
118
+ socialEngineering: {
119
+ severity: 'MEDIUM',
120
+ patterns: [
121
+ {
122
+ name: 'Authority Impersonation',
123
+ regex: /(i\s+am|this\s+is)\s+(the\s+)?(admin|administrator|developer|ceo|owner|anthropic|openai)/gi,
124
+ description: 'Impersonates authority figure',
125
+ cwe: 'CWE-346'
126
+ },
127
+ {
128
+ name: 'Urgency Manipulation',
129
+ regex: /(urgent|emergency|critical|immediately).*(?:do\s+this|execute|run|bypass)/gi,
130
+ description: 'Creates false urgency to bypass checks',
131
+ cwe: 'CWE-346'
132
+ },
133
+ {
134
+ name: 'Emotional Manipulation',
135
+ regex: /(please\s+)?(help\s+me|i\s+need|my\s+(life|job|family)).*(?:ignore|bypass|override)/gi,
136
+ description: 'Uses emotional appeals to manipulate',
137
+ cwe: 'CWE-346'
138
+ }
139
+ ]
140
+ },
141
+
142
+ // MEDIUM: Code execution and command injection
143
+ codeExecution: {
144
+ severity: 'MEDIUM',
145
+ patterns: [
146
+ {
147
+ name: 'Embedded Code Execution',
148
+ regex: /(execute|run|eval)\s+(this\s+)?(code|command|script|function):\s*```/gi,
149
+ description: 'Attempts to execute embedded code',
150
+ cwe: 'CWE-94'
151
+ },
152
+ {
153
+ name: 'Shell Command Injection',
154
+ regex: /```(bash|sh|zsh|cmd|powershell)[\s\S]{0,50}(rm\s+-rf|del\s+\/|curl.*\||wget.*\||nc\s+-)/gi,
155
+ description: 'Contains dangerous shell commands',
156
+ cwe: 'CWE-78'
157
+ },
158
+ {
159
+ name: 'SQL Injection in Prompts',
160
+ regex: /(execute|run)\s+query:?\s*(['"`])?.*(?:DROP|DELETE|INSERT|UPDATE).*(?:users?|admin|password)/gi,
161
+ description: 'SQL injection attempt in prompts',
162
+ cwe: 'CWE-89'
163
+ }
164
+ ]
165
+ },
166
+
167
+ // LOW: Suspicious patterns and metadata issues
168
+ suspiciousPatterns: {
169
+ severity: 'LOW',
170
+ patterns: [
171
+ {
172
+ name: 'Hidden Instructions',
173
+ regex: /<!--[\s\S]*?(?:ignore|bypass|execute)[\s\S]*?-->/gi,
174
+ description: 'Hidden instructions in HTML comments',
175
+ cwe: 'CWE-506'
176
+ },
177
+ {
178
+ name: 'Unicode Obfuscation',
179
+ regex: /[\u200B-\u200D\uFEFF]/g,
180
+ description: 'Zero-width characters for obfuscation',
181
+ cwe: 'CWE-838'
182
+ },
183
+ {
184
+ name: 'Excessive System References',
185
+ regex: /(system|instruction|directive|constraint|limitation)/gi,
186
+ description: 'Unusual focus on system internals',
187
+ cwe: 'CWE-200'
188
+ },
189
+ {
190
+ name: 'Base64 Encoded Prompts',
191
+ regex: /(?:prompt|instruction|command).*[A-Za-z0-9+/]{40,}={0,2}/gi,
192
+ description: 'Potentially obfuscated instructions',
193
+ cwe: 'CWE-838'
194
+ }
195
+ ]
196
+ }
197
+ };
198
+
199
+ /**
200
+ * Metadata validation patterns
201
+ * Checks for misleading or incomplete skill descriptions
202
+ */
203
+ const METADATA_VALIDATORS = {
204
+ descriptionQuality: {
205
+ minLength: 20,
206
+ maxLength: 500,
207
+ requiresPurpose: true
208
+ },
209
+ authorValidation: {
210
+ requiresAuthor: true,
211
+ blockedAuthors: ['test', 'admin', 'root', 'system']
212
+ },
213
+ versionValidation: {
214
+ requiresVersion: false,
215
+ semanticVersion: /^\d+\.\d+\.\d+$/
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Scan a SKILL.md file for prompt injection vulnerabilities
221
+ */
222
+ export async function scanSkillPrompt(skillPath, options = {}) {
223
+ const verbosity = options.verbosity || 'compact';
224
+ const results = {
225
+ skillPath,
226
+ findings: [],
227
+ metadata: {},
228
+ score: 100,
229
+ grade: 'A',
230
+ summary: {
231
+ critical: 0,
232
+ high: 0,
233
+ medium: 0,
234
+ low: 0
235
+ }
236
+ };
237
+
238
+ try {
239
+ // Read SKILL.md file
240
+ const content = await fs.readFile(skillPath, 'utf-8');
241
+
242
+ // Parse frontmatter metadata
243
+ results.metadata = parseSkillMetadata(content);
244
+
245
+ // Scan for prompt injection patterns
246
+ for (const [category, config] of Object.entries(PROMPT_PATTERNS)) {
247
+ for (const pattern of config.patterns) {
248
+ const matches = findMatches(content, pattern.regex);
249
+
250
+ if (matches.length > 0) {
251
+ const finding = {
252
+ category,
253
+ severity: config.severity,
254
+ name: pattern.name,
255
+ description: pattern.description,
256
+ cwe: pattern.cwe,
257
+ matches: matches.map(m => ({
258
+ line: m.line,
259
+ column: m.column,
260
+ snippet: m.snippet
261
+ })),
262
+ count: matches.length
263
+ };
264
+
265
+ results.findings.push(finding);
266
+ results.summary[config.severity.toLowerCase()]++;
267
+
268
+ // Deduct points based on severity
269
+ const deduction = {
270
+ 'CRITICAL': 30,
271
+ 'HIGH': 20,
272
+ 'MEDIUM': 10,
273
+ 'LOW': 5
274
+ }[config.severity] || 5;
275
+
276
+ results.score -= deduction * matches.length;
277
+ }
278
+ }
279
+ }
280
+
281
+ // Validate metadata
282
+ const metadataIssues = validateMetadata(results.metadata);
283
+ results.findings.push(...metadataIssues);
284
+
285
+ // Calculate final grade
286
+ results.score = Math.max(0, results.score);
287
+ results.grade = calculateGrade(results.score);
288
+
289
+ // Format output based on verbosity
290
+ return formatOutput(results, verbosity);
291
+
292
+ } catch (error) {
293
+ return {
294
+ error: error.message,
295
+ skillPath,
296
+ success: false
297
+ };
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Parse SKILL.md frontmatter metadata
303
+ */
304
+ function parseSkillMetadata(content) {
305
+ const metadata = {
306
+ name: null,
307
+ description: null,
308
+ author: null,
309
+ version: null,
310
+ tags: []
311
+ };
312
+
313
+ // Extract YAML frontmatter
314
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
315
+ if (frontmatterMatch) {
316
+ const yaml = frontmatterMatch[1];
317
+
318
+ // Simple YAML parsing (for basic fields)
319
+ metadata.name = yaml.match(/name:\s*(.+)/)?.[1]?.trim();
320
+ metadata.description = yaml.match(/description:\s*(.+)/)?.[1]?.trim();
321
+ metadata.author = yaml.match(/author:\s*(.+)/)?.[1]?.trim();
322
+ metadata.version = yaml.match(/version:\s*(.+)/)?.[1]?.trim();
323
+
324
+ const tagsMatch = yaml.match(/tags:\s*\[(.*?)\]/);
325
+ if (tagsMatch) {
326
+ metadata.tags = tagsMatch[1].split(',').map(t => t.trim().replace(/['"]/g, ''));
327
+ }
328
+ }
329
+
330
+ // Fallback: extract from first heading
331
+ if (!metadata.name) {
332
+ const headingMatch = content.match(/^#\s+(.+)$/m);
333
+ if (headingMatch) metadata.name = headingMatch[1];
334
+ }
335
+
336
+ return metadata;
337
+ }
338
+
339
+ /**
340
+ * Find all matches of a pattern in content with line/column info
341
+ */
342
+ function findMatches(content, regex) {
343
+ const matches = [];
344
+ const lines = content.split('\n');
345
+
346
+ lines.forEach((line, lineIndex) => {
347
+ let match;
348
+ const globalRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
349
+
350
+ while ((match = globalRegex.exec(line)) !== null) {
351
+ matches.push({
352
+ line: lineIndex + 1,
353
+ column: match.index,
354
+ snippet: line.substring(Math.max(0, match.index - 20), match.index + match[0].length + 20),
355
+ matchedText: match[0]
356
+ });
357
+ }
358
+ });
359
+
360
+ return matches;
361
+ }
362
+
363
+ /**
364
+ * Validate skill metadata
365
+ */
366
+ function validateMetadata(metadata) {
367
+ const issues = [];
368
+
369
+ // Description validation
370
+ if (!metadata.description) {
371
+ issues.push({
372
+ category: 'metadata',
373
+ severity: 'MEDIUM',
374
+ name: 'Missing Description',
375
+ description: 'Skill lacks a description',
376
+ cwe: 'CWE-1007'
377
+ });
378
+ } else if (metadata.description.length < METADATA_VALIDATORS.descriptionQuality.minLength) {
379
+ issues.push({
380
+ category: 'metadata',
381
+ severity: 'LOW',
382
+ name: 'Insufficient Description',
383
+ description: `Description too short (${metadata.description.length} chars, min ${METADATA_VALIDATORS.descriptionQuality.minLength})`,
384
+ cwe: 'CWE-1007'
385
+ });
386
+ }
387
+
388
+ // Author validation
389
+ if (!metadata.author) {
390
+ issues.push({
391
+ category: 'metadata',
392
+ severity: 'LOW',
393
+ name: 'Missing Author',
394
+ description: 'Skill lacks author information',
395
+ cwe: 'CWE-1007'
396
+ });
397
+ } else if (METADATA_VALIDATORS.authorValidation.blockedAuthors.includes(metadata.author.toLowerCase())) {
398
+ issues.push({
399
+ category: 'metadata',
400
+ severity: 'MEDIUM',
401
+ name: 'Suspicious Author Name',
402
+ description: `Author name "${metadata.author}" is suspicious`,
403
+ cwe: 'CWE-346'
404
+ });
405
+ }
406
+
407
+ return issues;
408
+ }
409
+
410
+ /**
411
+ * Calculate letter grade from score
412
+ */
413
+ function calculateGrade(score) {
414
+ if (score >= 90) return 'A';
415
+ if (score >= 75) return 'B';
416
+ if (score >= 60) return 'C';
417
+ if (score >= 45) return 'D';
418
+ return 'F';
419
+ }
420
+
421
+ /**
422
+ * Format output based on verbosity level
423
+ */
424
+ function formatOutput(results, verbosity) {
425
+ if (verbosity === 'minimal') {
426
+ return {
427
+ grade: results.grade,
428
+ score: results.score,
429
+ critical: results.summary.critical,
430
+ high: results.summary.high
431
+ };
432
+ }
433
+
434
+ if (verbosity === 'compact') {
435
+ return {
436
+ skillPath: results.skillPath,
437
+ grade: results.grade,
438
+ score: results.score,
439
+ summary: results.summary,
440
+ topIssues: results.findings
441
+ .filter(f => f.severity === 'CRITICAL' || f.severity === 'HIGH')
442
+ .slice(0, 5)
443
+ .map(f => ({
444
+ severity: f.severity,
445
+ name: f.name,
446
+ description: f.description,
447
+ count: f.count || 1
448
+ }))
449
+ };
450
+ }
451
+
452
+ // Full verbosity
453
+ return results;
454
+ }
455
+
456
+ /**
457
+ * Scan all SKILL.md files in a directory
458
+ */
459
+ export async function scanSkillDirectory(dirPath, options = {}) {
460
+ const results = {
461
+ scanned: 0,
462
+ findings: [],
463
+ summary: {
464
+ gradeA: 0,
465
+ gradeB: 0,
466
+ gradeC: 0,
467
+ gradeD: 0,
468
+ gradeF: 0,
469
+ totalCritical: 0,
470
+ totalHigh: 0,
471
+ totalMedium: 0,
472
+ totalLow: 0
473
+ }
474
+ };
475
+
476
+ try {
477
+ const skills = await findSkillFiles(dirPath);
478
+
479
+ for (const skillPath of skills) {
480
+ const scanResult = await scanSkillPrompt(skillPath, options);
481
+
482
+ if (scanResult.grade) {
483
+ results.scanned++;
484
+ results.summary[`grade${scanResult.grade}`]++;
485
+ results.summary.totalCritical += scanResult.summary?.critical || 0;
486
+ results.summary.totalHigh += scanResult.summary?.high || 0;
487
+ results.summary.totalMedium += scanResult.summary?.medium || 0;
488
+ results.summary.totalLow += scanResult.summary?.low || 0;
489
+
490
+ results.findings.push({
491
+ skill: path.basename(path.dirname(skillPath)),
492
+ ...scanResult
493
+ });
494
+ }
495
+ }
496
+
497
+ return results;
498
+
499
+ } catch (error) {
500
+ return {
501
+ error: error.message,
502
+ success: false
503
+ };
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Find all SKILL.md files recursively
509
+ */
510
+ async function findSkillFiles(dirPath) {
511
+ const skillFiles = [];
512
+
513
+ async function traverse(currentPath) {
514
+ const entries = await fs.readdir(currentPath, { withFileTypes: true });
515
+
516
+ for (const entry of entries) {
517
+ const fullPath = path.join(currentPath, entry.name);
518
+
519
+ if (entry.isDirectory()) {
520
+ await traverse(fullPath);
521
+ } else if (entry.name === 'SKILL.md' || entry.name === 'skill.md') {
522
+ skillFiles.push(fullPath);
523
+ }
524
+ }
525
+ }
526
+
527
+ await traverse(dirPath);
528
+ return skillFiles;
529
+ }
530
+
531
+ // CLI support
532
+ if (import.meta.url === `file://${process.argv[1]}`) {
533
+ const skillPath = process.argv[2];
534
+ const verbosity = process.argv[3] || 'compact';
535
+
536
+ if (!skillPath) {
537
+ console.error('Usage: node scan-skill-prompt.js <skill-path> [verbosity]');
538
+ process.exit(1);
539
+ }
540
+
541
+ const stats = await fs.stat(skillPath);
542
+ const results = stats.isDirectory()
543
+ ? await scanSkillDirectory(skillPath, { verbosity })
544
+ : await scanSkillPrompt(skillPath, { verbosity });
545
+
546
+ console.log(JSON.stringify(results, null, 2));
547
+ }
package/src/utils.js CHANGED
@@ -86,7 +86,7 @@ export function runAnalyzer(filePath, engine = 'auto') {
86
86
  }
87
87
  const result = execFileSync('python3', args, {
88
88
  encoding: 'utf-8',
89
- timeout: 30000
89
+ timeout: 45000 // Increased to 45s to match daemon timeout
90
90
  });
91
91
  return JSON.parse(result);
92
92
  } catch (error) {