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.
- package/README.md +283 -3
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/index.js +191 -2
- package/package.json +15 -5
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/rules/openclaw.security.yaml +283 -0
- package/scripts/postinstall.js +25 -0
- package/skills/openclaw/SKILL.md +102 -0
- package/skills/security-scan-batch.md +107 -0
- package/skills/security-scanner.md +76 -0
- package/src/cli/init-hooks.js +164 -0
- package/src/cli/init.js +93 -0
- package/src/config.js +181 -0
- package/src/context.js +228 -0
- package/src/dedup.js +129 -0
- package/src/fix-patterns.js +66 -17
- package/src/tools/fix-security.js +31 -4
- package/src/tools/scan-diff.js +151 -0
- package/src/tools/scan-project.js +308 -0
- package/src/tools/scan-prompt.js +71 -1
- package/src/tools/scan-security.js +33 -5
- package/src/utils.js +76 -7
|
@@ -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
|
+
}
|
package/src/tools/scan-prompt.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
65
|
+
// Load project configuration
|
|
66
|
+
const config = loadConfig(file_path);
|
|
61
67
|
|
|
62
|
-
|
|
68
|
+
// Check file exclusion
|
|
69
|
+
if (shouldExcludeFile(file_path, config)) {
|
|
63
70
|
return {
|
|
64
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
fixed
|
|
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 = {
|