agent-security-scanner-mcp 3.2.0 → 3.4.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.
@@ -0,0 +1,308 @@
1
+ // src/tools/scan-project.js
2
+ import { z } from "zod";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
4
+ import { join, resolve, relative, extname, basename } from "path";
5
+ import { execFileSync } from "child_process";
6
+ import { scanSecurity } from './scan-security.js';
7
+ import { matchGlob, loadConfig, shouldExcludeFile } from '../config.js';
8
+ import { detectLanguage } from '../utils.js';
9
+
10
+ export const scanProjectSchema = {
11
+ directory_path: z.string().describe("Path to the directory to scan"),
12
+ recursive: z.boolean().optional().describe("Scan subdirectories recursively (default: true)"),
13
+ include_patterns: z.array(z.string()).optional().describe("Glob patterns to include (e.g. ['**/*.py', '**/*.js'])"),
14
+ exclude_patterns: z.array(z.string()).optional().describe("Glob patterns to exclude (e.g. ['*test*', 'vendor/**'])"),
15
+ diff_only: z.boolean().optional().describe("Only scan git-changed files"),
16
+ cross_file: z.boolean().optional().describe("Enable cross-file taint analysis (max 50 files)"),
17
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level")
18
+ };
19
+
20
+ // Scannable file extensions
21
+ const SCANNABLE_EXTENSIONS = new Set([
22
+ '.py', '.js', '.ts', '.tsx', '.jsx', '.java', '.go', '.rb', '.php',
23
+ '.rs', '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.cs',
24
+ '.tf', '.hcl', '.sql',
25
+ ]);
26
+
27
+ // Parse .gitignore into patterns
28
+ function parseGitignore(dirPath) {
29
+ const gitignorePath = join(dirPath, '.gitignore');
30
+ if (!existsSync(gitignorePath)) return [];
31
+
32
+ try {
33
+ const content = readFileSync(gitignorePath, 'utf-8');
34
+ return content.split('\n')
35
+ .map(line => line.trim())
36
+ .filter(line => line && !line.startsWith('#'))
37
+ .map(line => {
38
+ // Normalize: remove trailing slash for directories
39
+ if (line.endsWith('/')) return line.slice(0, -1) + '/**';
40
+ return line;
41
+ });
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // Check if a file path matches gitignore patterns
48
+ function isGitignored(filePath, patterns) {
49
+ const normalized = filePath.replace(/\\/g, '/');
50
+ return patterns.some(pattern => matchGlob(normalized, pattern));
51
+ }
52
+
53
+ // Recursively walk a directory, respecting exclusions
54
+ function walkDirectory(dirPath, options = {}) {
55
+ const { recursive = true, includePatterns = [], excludePatterns = [], gitignorePatterns = [], config } = options;
56
+ const files = [];
57
+
58
+ function walk(currentDir) {
59
+ let entries;
60
+ try {
61
+ entries = readdirSync(currentDir);
62
+ } catch {
63
+ return;
64
+ }
65
+
66
+ for (const entry of entries) {
67
+ // Skip hidden directories/files
68
+ if (entry.startsWith('.')) continue;
69
+
70
+ const fullPath = join(currentDir, entry);
71
+ const relativePath = relative(dirPath, fullPath);
72
+
73
+ let stat;
74
+ try {
75
+ stat = statSync(fullPath);
76
+ } catch {
77
+ continue;
78
+ }
79
+
80
+ if (stat.isDirectory()) {
81
+ // Skip common non-source directories
82
+ if (['node_modules', 'vendor', 'dist', 'build', '__pycache__', '.git',
83
+ 'venv', 'env', '.venv', 'target', 'coverage'].includes(entry)) continue;
84
+
85
+ // Skip gitignored directories
86
+ if (isGitignored(relativePath, gitignorePatterns)) continue;
87
+
88
+ if (recursive) walk(fullPath);
89
+ } else if (stat.isFile()) {
90
+ const ext = extname(entry).toLowerCase();
91
+ const base = basename(entry).toLowerCase();
92
+
93
+ // Check extension or special filenames
94
+ if (!SCANNABLE_EXTENSIONS.has(ext) && base !== 'dockerfile') continue;
95
+
96
+ // Check gitignore
97
+ if (isGitignored(relativePath, gitignorePatterns)) continue;
98
+
99
+ // Check config exclusions
100
+ if (config && shouldExcludeFile(relativePath, config)) continue;
101
+
102
+ // Check include patterns (if specified, only include matching files)
103
+ if (includePatterns.length > 0) {
104
+ const matches = includePatterns.some(p => matchGlob(relativePath, p));
105
+ if (!matches) continue;
106
+ }
107
+
108
+ // Check exclude patterns (if specified, skip matching files)
109
+ if (excludePatterns.length > 0) {
110
+ const excluded = excludePatterns.some(p => matchGlob(relativePath, p) || relativePath.includes(p) || entry.includes(p));
111
+ if (excluded) continue;
112
+ }
113
+
114
+ files.push(fullPath);
115
+ }
116
+ }
117
+ }
118
+
119
+ walk(dirPath);
120
+ return files;
121
+ }
122
+
123
+ // Get git-changed files in a directory
124
+ function getGitChangedFiles(dirPath) {
125
+ try {
126
+ const output = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
127
+ cwd: dirPath, encoding: 'utf-8', timeout: 10000
128
+ });
129
+ const untrackedOutput = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
130
+ cwd: dirPath, encoding: 'utf-8', timeout: 10000
131
+ });
132
+ const allFiles = [...output.trim().split('\n'), ...untrackedOutput.trim().split('\n')]
133
+ .filter(f => f.trim())
134
+ .map(f => resolve(dirPath, f))
135
+ .filter(f => existsSync(f));
136
+ return [...new Set(allFiles)];
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ // Calculate security grade based on findings
143
+ function calculateGrade(totalIssues, totalFiles, errorCount) {
144
+ if (totalFiles === 0) return 'A';
145
+ const density = totalIssues / totalFiles;
146
+
147
+ if (errorCount === 0 && density === 0) return 'A';
148
+ if (errorCount === 0 && density < 0.5) return 'B';
149
+ if (errorCount <= 2 && density < 1.5) return 'C';
150
+ if (errorCount <= 5 && density < 3) return 'D';
151
+ return 'F';
152
+ }
153
+
154
+ export async function scanProject({ directory_path, recursive, include_patterns, exclude_patterns, diff_only, cross_file, verbosity }) {
155
+ const dirPath = resolve(directory_path);
156
+
157
+ if (!existsSync(dirPath)) {
158
+ return {
159
+ content: [{ type: "text", text: JSON.stringify({ error: "Directory not found" }) }]
160
+ };
161
+ }
162
+
163
+ // Load config from directory
164
+ const config = loadConfig(join(dirPath, 'dummy.js'));
165
+ const gitignorePatterns = parseGitignore(dirPath);
166
+
167
+ // Get files to scan
168
+ let files;
169
+ if (diff_only) {
170
+ files = getGitChangedFiles(dirPath);
171
+ } else {
172
+ files = walkDirectory(dirPath, {
173
+ recursive: recursive !== false,
174
+ includePatterns: include_patterns || [],
175
+ excludePatterns: exclude_patterns || [],
176
+ gitignorePatterns,
177
+ config
178
+ });
179
+ }
180
+
181
+ // Filter to scannable extensions
182
+ files = files.filter(f => {
183
+ const ext = extname(f).toLowerCase();
184
+ const base = basename(f).toLowerCase();
185
+ return SCANNABLE_EXTENSIONS.has(ext) || base === 'dockerfile';
186
+ });
187
+
188
+ if (files.length === 0) {
189
+ return {
190
+ content: [{ type: "text", text: JSON.stringify({
191
+ directory: dirPath,
192
+ message: "No scannable files found",
193
+ files_scanned: 0,
194
+ grade: 'A'
195
+ }) }]
196
+ };
197
+ }
198
+
199
+ // Scan each file
200
+ const allIssues = [];
201
+ const byFile = {};
202
+ const bySeverity = { error: 0, warning: 0, info: 0 };
203
+ const byCategory = {};
204
+
205
+ for (const filePath of files) {
206
+ const result = await scanSecurity({ file_path: filePath, verbosity: 'full' });
207
+ const parsed = JSON.parse(result.content[0].text);
208
+
209
+ if (parsed.issues && Array.isArray(parsed.issues)) {
210
+ const relativePath = relative(dirPath, filePath);
211
+ byFile[relativePath] = parsed.issues.length;
212
+
213
+ for (const issue of parsed.issues) {
214
+ allIssues.push({ ...issue, file: relativePath });
215
+ bySeverity[issue.severity] = (bySeverity[issue.severity] || 0) + 1;
216
+ const category = issue.ruleId?.split('.')[0] || 'other';
217
+ byCategory[category] = (byCategory[category] || 0) + 1;
218
+ }
219
+ }
220
+ }
221
+
222
+ // Cross-file taint analysis (opt-in, max 50 files)
223
+ let crossFileIssues = [];
224
+ if (cross_file && files.length <= 50) {
225
+ try {
226
+ const { runCrossFileAnalyzer } = await import('../utils.js');
227
+ if (typeof runCrossFileAnalyzer === 'function') {
228
+ const crossResults = runCrossFileAnalyzer(files);
229
+ if (Array.isArray(crossResults)) {
230
+ crossFileIssues = crossResults;
231
+ for (const issue of crossFileIssues) {
232
+ const relativePath = relative(dirPath, issue.file || '');
233
+ allIssues.push({ ...issue, file: relativePath });
234
+ bySeverity[issue.severity] = (bySeverity[issue.severity] || 0) + 1;
235
+ }
236
+ }
237
+ }
238
+ } catch {
239
+ // Cross-file analysis not available
240
+ }
241
+ }
242
+
243
+ const grade = calculateGrade(allIssues.length, files.length, bySeverity.error);
244
+ const level = verbosity || 'compact';
245
+
246
+ if (level === 'minimal') {
247
+ return {
248
+ content: [{ type: "text", text: JSON.stringify({
249
+ directory: dirPath,
250
+ files_scanned: files.length,
251
+ total: allIssues.length,
252
+ critical: bySeverity.error,
253
+ warning: bySeverity.warning,
254
+ info: bySeverity.info,
255
+ grade,
256
+ message: allIssues.length > 0
257
+ ? `Found ${allIssues.length} issue(s) across ${files.length} files. Grade: ${grade}`
258
+ : `No issues found in ${files.length} files. Grade: ${grade}`
259
+ }) }]
260
+ };
261
+ }
262
+
263
+ if (level === 'compact') {
264
+ // Show top issues per file, sorted by severity
265
+ const topIssues = allIssues
266
+ .sort((a, b) => {
267
+ const order = { error: 0, warning: 1, info: 2 };
268
+ return (order[a.severity] || 2) - (order[b.severity] || 2);
269
+ })
270
+ .slice(0, 50)
271
+ .map(i => ({
272
+ file: i.file,
273
+ line: (i.line || 0) + 1,
274
+ ruleId: i.ruleId,
275
+ severity: i.severity,
276
+ message: i.message
277
+ }));
278
+
279
+ return {
280
+ content: [{ type: "text", text: JSON.stringify({
281
+ directory: dirPath,
282
+ files_scanned: files.length,
283
+ issues_count: allIssues.length,
284
+ grade,
285
+ by_severity: bySeverity,
286
+ by_category: byCategory,
287
+ cross_file_issues: crossFileIssues.length > 0 ? crossFileIssues.length : undefined,
288
+ issues: topIssues
289
+ }, null, 2) }]
290
+ };
291
+ }
292
+
293
+ // full
294
+ return {
295
+ content: [{ type: "text", text: JSON.stringify({
296
+ directory: dirPath,
297
+ files_scanned: files.length,
298
+ issues_count: allIssues.length,
299
+ grade,
300
+ by_severity: bySeverity,
301
+ by_category: byCategory,
302
+ by_file: byFile,
303
+ cross_file_issues: crossFileIssues.length > 0 ? crossFileIssues : undefined,
304
+ issues: allIssues,
305
+ scanned_files: files.map(f => relative(dirPath, f))
306
+ }, null, 2) }]
307
+ };
308
+ }
@@ -39,6 +39,12 @@ const CATEGORY_WEIGHTS = {
39
39
  "prompt-injection-privilege": 0.85,
40
40
  "prompt-injection-multi-turn": 0.7,
41
41
  "prompt-injection-output": 0.9,
42
+ // OpenClaw-specific categories
43
+ "data_exfiltration": 1.0,
44
+ "messaging_abuse": 0.95,
45
+ "credential_theft": 1.0,
46
+ "autonomous_harm": 0.9,
47
+ "service_attack": 0.95,
42
48
  "unknown": 0.5
43
49
  };
44
50
 
@@ -189,6 +195,69 @@ function loadPromptInjectionRules() {
189
195
  }
190
196
  }
191
197
 
198
+ // Load OpenClaw-specific rules
199
+ function loadOpenClawRules() {
200
+ try {
201
+ const rulesPath = join(__dirname, '..', '..', 'rules', 'openclaw.security.yaml');
202
+ if (!existsSync(rulesPath)) {
203
+ return [];
204
+ }
205
+
206
+ const yaml = readFileSync(rulesPath, 'utf-8');
207
+ const rules = [];
208
+
209
+ const ruleBlocks = yaml.split(/^ - id:/m).slice(1);
210
+
211
+ for (const block of ruleBlocks) {
212
+ const lines = (' - id:' + block).split('\n');
213
+ const rule = {
214
+ id: '',
215
+ severity: 'WARNING',
216
+ message: '',
217
+ patterns: [],
218
+ metadata: {}
219
+ };
220
+
221
+ let inPatterns = false;
222
+
223
+ for (const line of lines) {
224
+ if (line.match(/^\s+- id:\s*/)) {
225
+ rule.id = line.replace(/^\s+- id:\s*/, '').trim();
226
+ } else if (line.match(/^\s+severity:\s*/)) {
227
+ rule.severity = line.replace(/^\s+severity:\s*/, '').trim();
228
+ } else if (line.match(/^\s+category:\s*/)) {
229
+ rule.metadata.category = line.replace(/^\s+category:\s*/, '').trim();
230
+ } else if (line.match(/^\s+action:\s*/)) {
231
+ rule.metadata.action = line.replace(/^\s+action:\s*/, '').trim();
232
+ } else if (line.match(/^\s+message:\s*/)) {
233
+ rule.message = line.replace(/^\s+message:\s*["']?/, '').replace(/["']$/, '').trim();
234
+ } else if (line.match(/^\s+patterns:\s*$/)) {
235
+ inPatterns = true;
236
+ } else if (inPatterns && line.match(/^\s+- /)) {
237
+ let pattern = line.replace(/^\s+- /, '').trim();
238
+ pattern = pattern.replace(/^["']|["']$/g, '');
239
+ pattern = pattern.replace(/\\\\/g, '\\');
240
+ if (pattern) rule.patterns.push(pattern);
241
+ } else if (line.match(/^\s+\w+:/) && !line.match(/^\s+- /)) {
242
+ inPatterns = false;
243
+ }
244
+ }
245
+
246
+ if (rule.id && rule.patterns.length > 0) {
247
+ // Set confidence and risk score based on severity
248
+ rule.metadata.confidence = rule.severity === 'CRITICAL' ? 'HIGH' : 'MEDIUM';
249
+ rule.metadata.risk_score = rule.severity === 'CRITICAL' ? '90' : '70';
250
+ rules.push(rule);
251
+ }
252
+ }
253
+
254
+ return rules;
255
+ } catch (error) {
256
+ console.error("Error loading OpenClaw rules:", error.message);
257
+ return [];
258
+ }
259
+ }
260
+
192
261
  // Calculate risk score from findings
193
262
  function calculateRiskScore(findings, context) {
194
263
  if (findings.length === 0) return 0;
@@ -377,7 +446,8 @@ export async function scanAgentPrompt({ prompt_text, context, verbosity }) {
377
446
  // Load rules
378
447
  const agentRules = loadAgentAttackRules();
379
448
  const promptRules = loadPromptInjectionRules();
380
- const allRules = [...agentRules, ...promptRules];
449
+ const openclawRules = loadOpenClawRules();
450
+ const allRules = [...agentRules, ...promptRules, ...openclawRules];
381
451
 
382
452
  // 2.7: Extract content from code blocks and append to scan text
383
453
  let expandedText = prompt_text;
@@ -2,11 +2,15 @@
2
2
  import { z } from "zod";
3
3
  import { existsSync, readFileSync } from "fs";
4
4
  import { detectLanguage, runAnalyzer, generateFix, toSarif } from '../utils.js';
5
+ import { deduplicateFindings } from '../dedup.js';
6
+ import { applyContextFilter, detectFrameworks, applyFrameworkAdjustments } from '../context.js';
7
+ import { loadConfig, shouldExcludeFile, applyConfig } from '../config.js';
5
8
 
6
9
  export const scanSecuritySchema = {
7
10
  file_path: z.string().describe("Path to the file to scan"),
8
11
  output_format: z.enum(['json', 'sarif']).optional().describe("Output format: 'json' (default) or 'sarif' for GitHub/GitLab integration"),
9
- verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)")
12
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level: 'minimal' (counts only), 'compact' (default, actionable info), 'full' (complete metadata)"),
13
+ engine: z.enum(['auto', 'ast', 'regex']).optional().describe("Analysis engine: 'auto' (default, AST with regex fallback), 'ast' (tree-sitter only), 'regex' (regex only)")
10
14
  };
11
15
 
12
16
  // Verbosity formatters
@@ -35,6 +39,7 @@ function formatCompact(file_path, language, issues) {
35
39
  line: i.line + 1,
36
40
  ruleId: i.ruleId,
37
41
  severity: i.severity,
42
+ confidence: i.confidence || 'MEDIUM',
38
43
  message: i.message,
39
44
  fix: i.suggested_fix?.fixed ? i.suggested_fix.fixed.trim() : null
40
45
  }))
@@ -50,26 +55,49 @@ function formatFull(file_path, language, issues) {
50
55
  };
51
56
  }
52
57
 
53
- export async function scanSecurity({ file_path, output_format, verbosity }) {
58
+ export async function scanSecurity({ file_path, output_format, verbosity, engine }) {
54
59
  if (!existsSync(file_path)) {
55
60
  return {
56
61
  content: [{ type: "text", text: JSON.stringify({ error: "File not found" }) }]
57
62
  };
58
63
  }
59
64
 
60
- const issues = runAnalyzer(file_path);
65
+ // Load project configuration
66
+ const config = loadConfig(file_path);
61
67
 
62
- if (issues.error) {
68
+ // Check file exclusion
69
+ if (shouldExcludeFile(file_path, config)) {
63
70
  return {
64
- content: [{ type: "text", text: JSON.stringify(issues) }]
71
+ content: [{ type: "text", text: JSON.stringify({ file: file_path, message: "File excluded by configuration", issues_count: 0 }) }]
65
72
  };
66
73
  }
67
74
 
75
+ const rawIssues = runAnalyzer(file_path, engine || 'auto');
76
+
77
+ if (rawIssues.error) {
78
+ return {
79
+ content: [{ type: "text", text: JSON.stringify(rawIssues) }]
80
+ };
81
+ }
82
+
83
+ // Cross-engine deduplication
84
+ const dedupedIssues = deduplicateFindings(rawIssues);
85
+
68
86
  // Read file content for fix suggestions
69
87
  const content = readFileSync(file_path, 'utf-8');
70
88
  const lines = content.split('\n');
71
89
  const language = detectLanguage(file_path);
72
90
 
91
+ // Context-aware filtering (suppress known module imports)
92
+ const contextFiltered = applyContextFilter(dedupedIssues, file_path, language);
93
+
94
+ // Framework-aware severity adjustment
95
+ const frameworks = detectFrameworks(file_path, language);
96
+ const frameworkAdjusted = applyFrameworkAdjustments(contextFiltered, frameworks);
97
+
98
+ // Apply .scannerrc configuration (rule suppression, severity/confidence thresholds)
99
+ const issues = applyConfig(frameworkAdjusted, file_path, config);
100
+
73
101
  // Enhance issues with fix suggestions
74
102
  const enhancedIssues = issues.map(issue => {
75
103
  const line = lines[issue.line] || '';
package/src/utils.js CHANGED
@@ -36,10 +36,14 @@ export function detectLanguage(filePath) {
36
36
  }
37
37
 
38
38
  // Run the Python analyzer
39
- export function runAnalyzer(filePath) {
39
+ export function runAnalyzer(filePath, engine = 'auto') {
40
40
  try {
41
41
  const analyzerPath = join(__dirname, '..', 'analyzer.py');
42
- const result = execFileSync('python3', [analyzerPath, filePath], {
42
+ const args = [analyzerPath, filePath];
43
+ if (engine !== 'auto') {
44
+ args.push('--engine', engine);
45
+ }
46
+ const result = execFileSync('python3', args, {
43
47
  encoding: 'utf-8',
44
48
  timeout: 30000
45
49
  });
@@ -49,17 +53,62 @@ export function runAnalyzer(filePath) {
49
53
  }
50
54
  }
51
55
 
56
+ // Validate that a fix produces syntactically reasonable output
57
+ export function validateFix(original, fixed) {
58
+ if (!fixed || fixed === original) return false;
59
+
60
+ // Strip escaped quotes for bracket/quote counting
61
+ const unescaped = fixed.replace(/\\["'`]/g, '');
62
+
63
+ // Check balanced quotes (single pass)
64
+ const singleQ = (unescaped.match(/'/g) || []).length;
65
+ const doubleQ = (unescaped.match(/"/g) || []).length;
66
+ const backtickQ = (unescaped.match(/`/g) || []).length;
67
+ if (singleQ % 2 !== 0 || doubleQ % 2 !== 0 || backtickQ % 2 !== 0) return false;
68
+
69
+ // Check balanced brackets
70
+ const brackets = { '(': 0, '[': 0, '{': 0 };
71
+ const closers = { ')': '(', ']': '[', '}': '{' };
72
+ for (const char of unescaped) {
73
+ if (brackets[char] !== undefined) brackets[char]++;
74
+ if (closers[char]) {
75
+ brackets[closers[char]]--;
76
+ if (brackets[closers[char]] < 0) return false;
77
+ }
78
+ }
79
+ if (Object.values(brackets).some(v => v !== 0)) return false;
80
+
81
+ return true;
82
+ }
83
+
52
84
  // Generate fix suggestion for an issue
53
85
  export function generateFix(issue, line, language) {
54
86
  const ruleId = issue.ruleId.toLowerCase();
55
87
 
56
88
  for (const [pattern, template] of Object.entries(FIX_TEMPLATES)) {
57
89
  if (ruleId.includes(pattern)) {
58
- return {
59
- description: template.description,
60
- original: line,
61
- fixed: template.fix(line, language)
62
- };
90
+ try {
91
+ const fixed = template.fix(line, language);
92
+ // Validate the fix produces reasonable output
93
+ if (fixed && !validateFix(line, fixed)) {
94
+ return {
95
+ description: template.description + " (manual fix required)",
96
+ original: line,
97
+ fixed: null
98
+ };
99
+ }
100
+ return {
101
+ description: template.description,
102
+ original: line,
103
+ fixed: fixed
104
+ };
105
+ } catch {
106
+ return {
107
+ description: template.description + " (manual fix required)",
108
+ original: line,
109
+ fixed: null
110
+ };
111
+ }
63
112
  }
64
113
  }
65
114
 
@@ -70,6 +119,26 @@ export function generateFix(issue, line, language) {
70
119
  };
71
120
  }
72
121
 
122
+ // Run cross-file taint analysis
123
+ export function runCrossFileAnalyzer(filePaths) {
124
+ try {
125
+ const analyzerPath = join(__dirname, '..', 'cross_file_analyzer.py');
126
+ if (!existsSync(analyzerPath)) return [];
127
+ const result = execFileSync('python3', [analyzerPath, ...filePaths], {
128
+ encoding: 'utf-8',
129
+ timeout: 120000,
130
+ maxBuffer: 10 * 1024 * 1024
131
+ });
132
+ const parsed = JSON.parse(result);
133
+ // Return only cross-file warnings (per-file findings are handled by scanSecurity)
134
+ return Array.isArray(parsed)
135
+ ? parsed.filter(f => f.ruleId === 'cross-file-taint-warning')
136
+ : [];
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
73
142
  // Convert issues to SARIF 2.1.0 format
74
143
  export function toSarif(file_path, language, issues) {
75
144
  const severityToLevel = {