agent-security-scanner-mcp 3.17.2 → 3.19.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.
@@ -20,7 +20,7 @@ export const FIX_TEMPLATES = {
20
20
  // ===========================================
21
21
  "sql-injection": {
22
22
  description: "Use parameterized queries instead of string concatenation",
23
- fix: (line) => line.replace(/["']([^"']*)\s*["']\s*\+\s*(\w+)/, '"$1?", [$2]')
23
+ fix: (line) => '// TODO: manual fix required — use parameterized queries instead of string concatenation\n// ' + line.trim()
24
24
  },
25
25
  "nosql-injection": {
26
26
  description: "Sanitize MongoDB query inputs",
@@ -28,7 +28,7 @@ export const FIX_TEMPLATES = {
28
28
  },
29
29
  "raw-query": {
30
30
  description: "Use parameterized queries instead of raw SQL",
31
- fix: (line) => line.replace(/\.query\s*\(\s*["'`]/, '.query("SELECT * FROM table WHERE id = ?", [')
31
+ fix: (line) => '// TODO: manual fix required use parameterized queries instead of raw SQL\n// ' + line.trim()
32
32
  },
33
33
 
34
34
  // ===========================================
@@ -306,10 +306,10 @@ export const FIX_TEMPLATES = {
306
306
  "path-traversal": {
307
307
  description: "Resolve real path and validate prefix to prevent traversal",
308
308
  fix: (line, lang) => {
309
- if (lang === 'python') return line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.realpath($1) # TODO: validate path prefix');
310
- if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Clean($1) // TODO: validate path prefix');
311
- if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File($1).getCanonicalFile( // TODO: validate path prefix');
312
- return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.resolve($1) // TODO: validate path prefix');
309
+ if (lang === 'python') return '# TODO: manual fix required — use os.path.realpath() and validate the prefix\n# ' + line.trim();
310
+ if (lang === 'go') return '// TODO: manual fix required — use filepath.Clean() and validate the prefix\n// ' + line.trim();
311
+ if (lang === 'java') return '// TODO: manual fix required — use getCanonicalFile() and validate the prefix\n// ' + line.trim();
312
+ return '// TODO: manual fix required — use path.resolve() and validate the prefix\n// ' + line.trim();
313
313
  }
314
314
  },
315
315
 
@@ -418,7 +418,7 @@ export const FIX_TEMPLATES = {
418
418
  // ===========================================
419
419
  "xpath-injection": {
420
420
  description: "Use parameterized XPath queries",
421
- fix: (line) => line.replace(/xpath\s*\(\s*["']([^"']*)\s*["']\s*\+\s*(\w+)/, 'xpath("$1?", [$2]')
421
+ fix: (line) => '// TODO: manual fix required — use parameterized XPath queries instead of concatenation\n// ' + line.trim()
422
422
  },
423
423
 
424
424
  // ===========================================
@@ -695,9 +695,9 @@ export const FIX_TEMPLATES = {
695
695
  description: "CRITICAL: Never eval() LLM responses - use JSON parsing or ast.literal_eval for safe subset",
696
696
  fix: (line, lang) => {
697
697
  if (lang === 'python') {
698
- return line.replace(/eval\s*\(\s*(\w+)/, 'ast.literal_eval($1 # SECURITY: Use safe parsing only');
698
+ return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'ast.literal_eval($1) # SECURITY: Use safe parsing only');
699
699
  }
700
- return line.replace(/eval\s*\(\s*(\w+)/, 'JSON.parse($1 /* SECURITY: Use safe JSON parsing */');
700
+ return line.replace(/eval\s*\(\s*(\w+)\s*\)/, 'JSON.parse($1) /* SECURITY: Use safe JSON parsing */');
701
701
  }
702
702
  },
703
703
  "exec-llm-response": {
package/src/history.js CHANGED
@@ -49,7 +49,7 @@ export function saveResult(dirPath, scanResult) {
49
49
  };
50
50
 
51
51
  writeFileSync(filePath, JSON.stringify(historyEntry, null, 2) + '\n');
52
- return filePath;
52
+ return filePath.replace(/\\/g, '/');
53
53
  }
54
54
 
55
55
  /**
@@ -32,6 +32,17 @@ const BLOOM_FILTERS = {
32
32
  rubygems: null
33
33
  };
34
34
 
35
+ // Flutter/Dart SDK packages are legitimate dependencies even though they do
36
+ // not appear in the pub.dev package dump used for the text-based lookup.
37
+ const DART_SDK_PACKAGES = new Set([
38
+ 'flutter',
39
+ 'flutter_test',
40
+ 'flutter_driver',
41
+ 'flutter_localizations',
42
+ 'flutter_web_plugins',
43
+ 'integration_test',
44
+ ]);
45
+
35
46
  // Load package lists on startup
36
47
  export function loadPackageLists() {
37
48
  const packagesDir = join(__dirname, '..', '..', 'packages');
@@ -67,6 +78,10 @@ export function loadPackageLists() {
67
78
 
68
79
  // Check if a package is hallucinated
69
80
  export function isHallucinated(packageName, ecosystem) {
81
+ if (ecosystem === 'dart' && DART_SDK_PACKAGES.has(packageName)) {
82
+ return { hallucinated: false, sdkPackage: true };
83
+ }
84
+
70
85
  const legitPackages = LEGITIMATE_PACKAGES[ecosystem];
71
86
 
72
87
  // First check Set-based lookup (exact match)
@@ -58,11 +58,41 @@ const CONFIDENCE_MULTIPLIERS = {
58
58
  // Maximum prompt size to prevent DoS via large inputs (100KB)
59
59
  const MAX_PROMPT_SIZE = 100 * 1024;
60
60
 
61
+ // Maximum text length fed to any single regex to prevent ReDoS.
62
+ // Prompt-injection patterns look for short markers/phrases, so scanning
63
+ // overlapping 2 KB windows covers all realistic payloads while keeping
64
+ // worst-case regex time bounded.
65
+ const REGEX_SCAN_WINDOW = 2048;
66
+ const REGEX_SCAN_OVERLAP = 256;
67
+
68
+ /**
69
+ * Match a regex against text safely — splits long text into overlapping
70
+ * windows so no single regex call processes more than REGEX_SCAN_WINDOW chars.
71
+ */
72
+ function safeMatch(text, regex) {
73
+ if (text.length <= REGEX_SCAN_WINDOW) {
74
+ return text.match(regex);
75
+ }
76
+ for (let offset = 0; offset < text.length; offset += REGEX_SCAN_WINDOW - REGEX_SCAN_OVERLAP) {
77
+ const chunk = text.slice(offset, offset + REGEX_SCAN_WINDOW);
78
+ const m = chunk.match(regex);
79
+ if (m) return m;
80
+ }
81
+ return null;
82
+ }
83
+
61
84
  // Rule caches — loaded once per process, not on every call
62
85
  let _agentAttackRulesCache = null;
63
86
  let _promptInjectionRulesCache = null;
64
87
  let _openClawRulesCache = null;
65
88
 
89
+ function normalizeYamlRegexPattern(pattern) {
90
+ return pattern
91
+ .replace(/^["']|["']$/g, '')
92
+ .replace(/\(\?i\)/g, '')
93
+ .replace(/\\\\/g, '\\');
94
+ }
95
+
66
96
  // Load agent attack rules from YAML
67
97
  function loadAgentAttackRules() {
68
98
  if (_agentAttackRulesCache !== null) return _agentAttackRulesCache;
@@ -108,11 +138,7 @@ function loadAgentAttackRules() {
108
138
  inMetadata = true;
109
139
  } else if (inPatterns && line.match(/^\s+- /)) {
110
140
  let pattern = line.replace(/^\s+- /, '').trim();
111
- pattern = pattern.replace(/^["']|["']$/g, '');
112
- // Strip Python-style inline flags - JS doesn't support them
113
- pattern = pattern.replace(/^\(\?i\)/, '');
114
- // Unescape double backslashes from YAML (\\s -> \s)
115
- pattern = pattern.replace(/\\\\/g, '\\');
141
+ pattern = normalizeYamlRegexPattern(pattern);
116
142
  if (pattern) rule.patterns.push(pattern);
117
143
  } else if (inMetadata && line.match(/^\s+\w+:/)) {
118
144
  const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
@@ -182,11 +208,7 @@ function loadPromptInjectionRules() {
182
208
  inMetadata = true;
183
209
  } else if (inPatterns && line.match(/^\s+- /)) {
184
210
  let pattern = line.replace(/^\s+- /, '').trim();
185
- pattern = pattern.replace(/^["']|["']$/g, '');
186
- // Strip Python-style inline flags - JS doesn't support them
187
- pattern = pattern.replace(/^\(\?i\)/, '');
188
- // Unescape double backslashes from YAML (\\s -> \s)
189
- pattern = pattern.replace(/\\\\/g, '\\');
211
+ pattern = normalizeYamlRegexPattern(pattern);
190
212
  if (pattern) rule.patterns.push(pattern);
191
213
  } else if (inMetadata && line.match(/^\s+\w+:/)) {
192
214
  const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
@@ -253,8 +275,7 @@ function loadOpenClawRules() {
253
275
  inPatterns = true;
254
276
  } else if (inPatterns && line.match(/^\s+- /)) {
255
277
  let pattern = line.replace(/^\s+- /, '').trim();
256
- pattern = pattern.replace(/^["']|["']$/g, '');
257
- pattern = pattern.replace(/\\\\/g, '\\');
278
+ pattern = normalizeYamlRegexPattern(pattern);
258
279
  if (pattern) rule.patterns.push(pattern);
259
280
  } else if (line.match(/^\s+\w+:/) && !line.match(/^\s+- /)) {
260
281
  inPatterns = false;
@@ -579,22 +600,12 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
579
600
  }
580
601
  }
581
602
 
582
- // Scan expanded text against all rules
583
- // Security: Add timeout protection for regex matching
584
- const REGEX_TIMEOUT_MS = 1000;
585
-
603
+ // Scan expanded text against all rules using windowed matching to prevent ReDoS
586
604
  for (const rule of allRules) {
587
605
  for (const pattern of rule.patterns) {
588
606
  try {
589
- const regex = new RegExp(pattern, 'i');
590
- const startTime = Date.now();
591
- const match = expandedText.match(regex);
592
-
593
- // Check for regex timeout (ReDoS protection)
594
- if (Date.now() - startTime > REGEX_TIMEOUT_MS) {
595
- console.warn(`Regex timeout for rule ${rule.id}, skipping`);
596
- break;
597
- }
607
+ const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
608
+ const match = safeMatch(expandedText, regex);
598
609
 
599
610
  if (match) {
600
611
  findings.push({
@@ -617,7 +628,9 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
617
628
  }
618
629
 
619
630
  // 2.8: Runtime base64 decode-and-rescan
620
- const base64Regex = /[A-Za-z0-9+/]{40,}={0,2}/g;
631
+ // Cap base64 match length to avoid matching entire large inputs as one blob.
632
+ // Real base64 payloads are at most a few KB; 4096 chars ≈ 3KB decoded.
633
+ const base64Regex = /[A-Za-z0-9+/]{40,4096}={0,2}/g;
621
634
  const b64Matches = expandedText.match(base64Regex);
622
635
  if (b64Matches) {
623
636
  for (const b64str of b64Matches) {
@@ -631,8 +644,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
631
644
  if (!rule.id.startsWith('generic.prompt')) continue;
632
645
  for (const pattern of rule.patterns) {
633
646
  try {
634
- const regex = new RegExp(pattern, 'i');
635
- const match = decoded.match(regex);
647
+ const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
648
+ const match = safeMatch(decoded, regex);
636
649
  if (match) {
637
650
  findings.push({
638
651
  rule_id: rule.id + '.base64-decoded',
@@ -674,8 +687,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
674
687
  for (const rule of allRules) {
675
688
  for (const pattern of rule.patterns) {
676
689
  try {
677
- const regex = new RegExp(pattern, 'i');
678
- const match = innerDecoded.match(regex);
690
+ const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
691
+ const match = safeMatch(innerDecoded, regex);
679
692
  if (match) {
680
693
  findings.push({
681
694
  rule_id: rule.id + '.nested-base64-decoded',
@@ -718,7 +731,7 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
718
731
  for (const rule of allRules) {
719
732
  for (const pattern of rule.patterns) {
720
733
  try {
721
- const regex = new RegExp(pattern, 'i');
734
+ const regex = new RegExp(normalizeYamlRegexPattern(pattern), 'i');
722
735
  if (regex.test(prevMsg)) {
723
736
  prevTotalScore += parseInt(rule.metadata?.risk_score || '50') / 100;
724
737
  msgHasMatch = true;
@@ -7,6 +7,7 @@ import { deduplicateFindings } from '../dedup.js';
7
7
  import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from '../context.js';
8
8
  import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
9
9
  import { discoverProjectContext } from './project-context.js';
10
+ import { runSemanticAnalysis, isSemanticAnalysisAvailable } from '../semantic-integration.js';
10
11
 
11
12
  const MAX_FILE_SIZE = 1024 * 1024; // 1MB - skip files larger than this to avoid timeouts
12
13
 
@@ -14,9 +15,10 @@ export const scanSecuritySchema = {
14
15
  file_path: z.string().describe("Path to the file to scan"),
15
16
  output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
16
17
  verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
17
- engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)"),
18
+ engine: z.enum(['auto', 'ast', 'regex', 'semantic', 'all']).optional().describe("Analysis engine: 'auto' (default, AST+semantic with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only), 'semantic' (semantic/CPG only), 'all' (all engines)"),
18
19
  project_context: z.boolean().optional().describe("Include project context (framework, security middleware, dependencies)"),
19
- include_context: z.boolean().optional().describe("Include surrounding code context for each issue")
20
+ include_context: z.boolean().optional().describe("Include surrounding code context for each issue"),
21
+ enable_semantic: z.boolean().optional().describe("Enable semantic/CPG analysis (default: true if available)")
20
22
  };
21
23
 
22
24
  // Verbosity formatters
@@ -64,7 +66,7 @@ function formatFull(file_path, language, issues) {
64
66
  };
65
67
  }
66
68
 
67
- export async function scanSecurity({ file_path, output_format, verbosity, engine, project_context, include_context }) {
69
+ export async function scanSecurity({ file_path, output_format, verbosity, engine, project_context, include_context, enable_semantic }) {
68
70
  if (!existsSync(file_path)) {
69
71
  return {
70
72
  content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }]
@@ -101,7 +103,34 @@ export async function scanSecurity({ file_path, output_format, verbosity, engine
101
103
  };
102
104
  }
103
105
 
104
- const rawIssues = await runAnalyzerAsync(file_path, engine || 'auto');
106
+ // Determine which engines to run
107
+ const engineMode = engine || 'auto';
108
+ const shouldRunSemantic = (enable_semantic !== false) &&
109
+ (engineMode === 'auto' || engineMode === 'semantic' || engineMode === 'all') &&
110
+ isSemanticAnalysisAvailable();
111
+
112
+ // Run primary analysis (AST/regex)
113
+ let rawIssues = [];
114
+ if (engineMode !== 'semantic') {
115
+ rawIssues = await runAnalyzerAsync(file_path, engineMode === 'all' ? 'auto' : engineMode);
116
+ if (rawIssues.error) {
117
+ return {
118
+ content: [{ type: "text", text: JSON.stringify(rawIssues) }]
119
+ };
120
+ }
121
+ }
122
+
123
+ // Run semantic analysis if enabled
124
+ if (shouldRunSemantic) {
125
+ try {
126
+ const semanticFindings = await runSemanticAnalysis(file_path);
127
+ if (semanticFindings && semanticFindings.length > 0) {
128
+ rawIssues = rawIssues.concat(semanticFindings);
129
+ }
130
+ } catch (error) {
131
+ console.error('[SEMANTIC] Analysis failed, continuing without semantic findings:', error.message);
132
+ }
133
+ }
105
134
 
106
135
  if (rawIssues.error) {
107
136
  return {
@@ -126,6 +126,12 @@ function normPath(p) { return IS_WIN ? p.toLowerCase() : p; }
126
126
  function pathStartsWith(child, parent) {
127
127
  return normPath(child) === normPath(parent) || normPath(child).startsWith(normPath(parent) + sep);
128
128
  }
129
+ function normalizeRulePattern(pattern) {
130
+ return pattern
131
+ .replace(/^["']|["']$/g, '')
132
+ .replace(/\(\?i\)/g, '')
133
+ .replace(/\\\\/g, '\\');
134
+ }
129
135
  const MAX_CLAWHAVOC_SCAN_LEN = 2 * 1024 * 1024; // 2 MB cap for regex matching
130
136
 
131
137
  // ---------------------------------------------------------------------------
@@ -176,9 +182,7 @@ function loadClawHavocRules() {
176
182
  inMetadata = true;
177
183
  } else if (inPatterns && line.match(/^\s+- /)) {
178
184
  let pattern = line.replace(/^\s+- /, '').trim();
179
- pattern = pattern.replace(/^["']|["']$/g, '');
180
- pattern = pattern.replace(/^\(\?i\)/, '');
181
- pattern = pattern.replace(/\\\\/g, '\\');
185
+ pattern = normalizeRulePattern(pattern);
182
186
  if (pattern) rule.patterns.push(pattern);
183
187
  } else if (inMetadata && line.match(/^\s+\w+:/)) {
184
188
  const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
@@ -892,37 +896,65 @@ function generateRecommendation(grade) {
892
896
  // ---------------------------------------------------------------------------
893
897
 
894
898
  export async function scanSkill({ skill_path, verbosity, baseline }) {
895
- // Security: Resolve to canonical path FIRST to prevent TOCTOU and symlink attacks
899
+ const canonCwd = realpathSync(process.cwd());
900
+ const configuredSkillRoots = [
901
+ resolve(homedir(), '.openclaw', 'skills'),
902
+ resolve(homedir(), '.openclaw', 'workspace', 'skills'),
903
+ ];
904
+ const allowedSkillRoots = configuredSkillRoots.map(root => {
905
+ try {
906
+ return existsSync(root) ? realpathSync(root) : null;
907
+ } catch {
908
+ return null;
909
+ }
910
+ }).filter(Boolean);
911
+
912
+ // Reject obvious escapes before touching the filesystem so absolute traversal
913
+ // attempts fail closed even when the target path does not exist.
896
914
  const inputPath = skill_path;
897
- let realPath;
915
+ const requestedPath = resolve(inputPath);
916
+ const isRequestedAllowed = pathStartsWith(requestedPath, canonCwd)
917
+ || configuredSkillRoots.some(root => pathStartsWith(requestedPath, root))
918
+ || allowedSkillRoots.some(root => pathStartsWith(requestedPath, root));
898
919
 
920
+ if (!isRequestedAllowed) {
921
+ return {
922
+ content: [{ type: "text", text: JSON.stringify({
923
+ error: "skill_path must be within the current working directory or ~/.openclaw/skills/ (or ~/.openclaw/workspace/skills/)",
924
+ skill_path: requestedPath,
925
+ attempted_path: inputPath
926
+ }) }]
927
+ };
928
+ }
929
+
930
+ // Resolve to canonical path after the initial boundary check to prevent
931
+ // symlink escapes while still returning a deterministic security error for
932
+ // out-of-scope absolute paths.
933
+ let realPath;
899
934
  try {
900
- // Resolve to canonical path immediately (defeats symlink attacks)
901
- realPath = realpathSync(resolve(inputPath));
935
+ realPath = realpathSync(requestedPath);
902
936
  } catch (err) {
937
+ let errorMessage;
938
+ if (err.code === 'ENOENT') {
939
+ errorMessage = "Path not found";
940
+ } else if (err.code === 'ELOOP') {
941
+ errorMessage = "Symlink loop detected";
942
+ } else if (err.code === 'EACCES') {
943
+ errorMessage = "Permission denied";
944
+ } else {
945
+ errorMessage = "Invalid path";
946
+ }
947
+
903
948
  return {
904
949
  content: [{ type: "text", text: JSON.stringify({
905
- error: "Invalid path, symlink loop, or permission denied",
950
+ error: errorMessage,
906
951
  skill_path: inputPath,
907
952
  details: err.message
908
953
  }) }]
909
954
  };
910
955
  }
911
956
 
912
- // Verify containment on canonical path ONLY
913
- // This prevents symlink escapes by checking the REAL resolved location
914
- const canonCwd = realpathSync(process.cwd());
915
- const allowedSkillRoots = [
916
- resolve(homedir(), '.openclaw', 'skills'),
917
- resolve(homedir(), '.openclaw', 'workspace', 'skills'),
918
- ].map(root => {
919
- try {
920
- return existsSync(root) ? realpathSync(root) : null;
921
- } catch {
922
- return null;
923
- }
924
- }).filter(Boolean);
925
-
957
+ // Verify containment on canonical path ONLY.
926
958
  const isAllowed = pathStartsWith(realPath, canonCwd)
927
959
  || allowedSkillRoots.some(root => pathStartsWith(realPath, root));
928
960