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
package/src/dedup.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Cross-engine deduplication for security findings.
|
|
2
|
+
// When AST and regex engines flag the same vulnerability on the same line
|
|
3
|
+
// with different ruleIds, this module merges them into a single finding.
|
|
4
|
+
|
|
5
|
+
// Maps ruleId substrings to vulnerability classes for cross-engine dedup.
|
|
6
|
+
// Order matters: more specific patterns must come before generic ones.
|
|
7
|
+
const VULN_CLASS_PATTERNS = [
|
|
8
|
+
// XSS variants
|
|
9
|
+
['innerhtml', 'xss-innerhtml'],
|
|
10
|
+
['outerhtml', 'xss-outerhtml'],
|
|
11
|
+
['document-write', 'xss-document-write'],
|
|
12
|
+
['document.write', 'xss-document-write'],
|
|
13
|
+
['insertadjacenthtml', 'xss-insertadjacenthtml'],
|
|
14
|
+
['dangerouslysetinnerhtml', 'xss-dangerouslysetinnerhtml'],
|
|
15
|
+
['mustache-escape', 'xss-innerhtml'],
|
|
16
|
+
['insecure-document-method', 'xss-document-write'],
|
|
17
|
+
['dom-based-xss', 'xss-dom'],
|
|
18
|
+
['xss-echo', 'xss-echo'],
|
|
19
|
+
['xss-raw', 'xss-raw'],
|
|
20
|
+
['xss-response-write', 'xss-response-write'],
|
|
21
|
+
|
|
22
|
+
// SQL Injection
|
|
23
|
+
['sql-injection', 'sqli'],
|
|
24
|
+
['nosql-injection', 'nosqli'],
|
|
25
|
+
|
|
26
|
+
// Command Injection
|
|
27
|
+
['child-process-exec', 'cmdi-exec'],
|
|
28
|
+
['spawn-shell', 'cmdi-spawn'],
|
|
29
|
+
['dangerous-subprocess', 'cmdi-subprocess'],
|
|
30
|
+
['dangerous-system-call', 'cmdi-system'],
|
|
31
|
+
['command-injection', 'cmdi'],
|
|
32
|
+
['backticks-exec', 'cmdi-backticks'],
|
|
33
|
+
['libc-system-call', 'cmdi-libc'],
|
|
34
|
+
|
|
35
|
+
// Code Injection
|
|
36
|
+
['eval-detected', 'code-eval'],
|
|
37
|
+
['eval-usage', 'code-eval'],
|
|
38
|
+
['exec-detected', 'code-exec'],
|
|
39
|
+
['function-constructor', 'code-function-constructor'],
|
|
40
|
+
|
|
41
|
+
// Deserialization
|
|
42
|
+
['pickle-load', 'deser-pickle'],
|
|
43
|
+
['unsafe-unserialize', 'deser-unserialize'],
|
|
44
|
+
['unsafe-yaml-load', 'deser-yaml'],
|
|
45
|
+
['yaml-load', 'deser-yaml'],
|
|
46
|
+
['unsafe-marshal', 'deser-marshal'],
|
|
47
|
+
['insecure-deserialization', 'deser'],
|
|
48
|
+
|
|
49
|
+
// Crypto
|
|
50
|
+
['md5', 'weak-hash-md5'],
|
|
51
|
+
['sha1', 'weak-hash-sha1'],
|
|
52
|
+
['insecure-hash', 'weak-hash'],
|
|
53
|
+
['weak-hash', 'weak-hash'],
|
|
54
|
+
['weak-cipher', 'weak-cipher'],
|
|
55
|
+
|
|
56
|
+
// Secrets
|
|
57
|
+
['hardcoded-password', 'hardcoded-password'],
|
|
58
|
+
['hardcoded-secret', 'hardcoded-secret'],
|
|
59
|
+
['hardcoded-api-key', 'hardcoded-api-key'],
|
|
60
|
+
['hardcoded-connection-string', 'hardcoded-connection-string'],
|
|
61
|
+
|
|
62
|
+
// Path traversal
|
|
63
|
+
['path-traversal', 'path-traversal'],
|
|
64
|
+
|
|
65
|
+
// SSL
|
|
66
|
+
['ssl-verify-disabled', 'ssl-verify-disabled'],
|
|
67
|
+
|
|
68
|
+
// Random
|
|
69
|
+
['insecure-random', 'insecure-random'],
|
|
70
|
+
['weak-random', 'weak-random'],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Engine priority (higher = more trusted analysis)
|
|
74
|
+
const ENGINE_PRIORITY = {
|
|
75
|
+
'taint': 3,
|
|
76
|
+
'ast': 2,
|
|
77
|
+
'regex': 1,
|
|
78
|
+
'regex-fallback': 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const SEVERITY_ORDER = { error: 3, warning: 2, info: 1 };
|
|
82
|
+
|
|
83
|
+
export function classifyFinding(ruleId) {
|
|
84
|
+
const lower = ruleId.toLowerCase();
|
|
85
|
+
for (const [pattern, vulnClass] of VULN_CLASS_PATTERNS) {
|
|
86
|
+
if (lower.includes(pattern)) return vulnClass;
|
|
87
|
+
}
|
|
88
|
+
return lower;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function deduplicateFindings(findings) {
|
|
92
|
+
if (!Array.isArray(findings)) return findings;
|
|
93
|
+
|
|
94
|
+
// Group by (vulnClass, line)
|
|
95
|
+
const groups = new Map();
|
|
96
|
+
for (const finding of findings) {
|
|
97
|
+
const vulnClass = classifyFinding(finding.ruleId);
|
|
98
|
+
const key = `${vulnClass}:${finding.line}`;
|
|
99
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
100
|
+
groups.get(key).push(finding);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const deduped = [];
|
|
104
|
+
for (const group of groups.values()) {
|
|
105
|
+
if (group.length === 1) {
|
|
106
|
+
deduped.push(group[0]);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sort by engine priority (highest first)
|
|
111
|
+
group.sort((a, b) =>
|
|
112
|
+
(ENGINE_PRIORITY[b.engine] || 0) - (ENGINE_PRIORITY[a.engine] || 0)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const best = { ...group[0] };
|
|
116
|
+
|
|
117
|
+
// Preserve highest severity across group
|
|
118
|
+
for (const f of group) {
|
|
119
|
+
if ((SEVERITY_ORDER[f.severity] || 0) > (SEVERITY_ORDER[best.severity] || 0)) {
|
|
120
|
+
best.severity = f.severity;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
best.engines_matched = [...new Set(group.map(f => f.engine))];
|
|
125
|
+
deduped.push(best);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return deduped;
|
|
129
|
+
}
|
package/src/fix-patterns.js
CHANGED
|
@@ -63,20 +63,56 @@ export const FIX_TEMPLATES = {
|
|
|
63
63
|
// COMMAND INJECTION
|
|
64
64
|
// ===========================================
|
|
65
65
|
"child-process-exec": {
|
|
66
|
-
description: "Use execFile()
|
|
67
|
-
fix: (line) =>
|
|
66
|
+
description: "Use execFile() with separate command and arguments array",
|
|
67
|
+
fix: (line) => {
|
|
68
|
+
// Match: exec("cmd " + arg) -> execFile("cmd", [arg])
|
|
69
|
+
const concatMatch = line.match(/\bexec\s*\(\s*["'](\S+)\s+["']\s*\+\s*(\w+)/);
|
|
70
|
+
if (concatMatch) {
|
|
71
|
+
return line.replace(/\bexec\s*\(\s*["'](\S+)\s+["']\s*\+\s*(\w+)\s*\)/, 'execFile("$1", [$2])');
|
|
72
|
+
}
|
|
73
|
+
// Match: exec(`cmd ${arg}`) -> execFile("cmd", [arg])
|
|
74
|
+
const templateMatch = line.match(/\bexec\s*\(\s*`(\S+)\s+\$\{(\w+)\}`/);
|
|
75
|
+
if (templateMatch) {
|
|
76
|
+
return line.replace(/\bexec\s*\(\s*`(\S+)\s+\$\{(\w+)\}`\s*\)/, 'execFile("$1", [$2])');
|
|
77
|
+
}
|
|
78
|
+
// Match: exec(variable) -> execFile with guidance
|
|
79
|
+
const varMatch = line.match(/\bexec\s*\(\s*(\w+)\s*\)/);
|
|
80
|
+
if (varMatch) {
|
|
81
|
+
return line.replace(/\bexec\s*\(\s*(\w+)\s*\)/, 'execFile($1.split(" ")[0], $1.split(" ").slice(1))');
|
|
82
|
+
}
|
|
83
|
+
// Fallback: comment with guidance
|
|
84
|
+
return '// SECURITY: Use execFile(command, [args]) instead of exec() - ' + line.trim();
|
|
85
|
+
}
|
|
68
86
|
},
|
|
69
87
|
"spawn-shell": {
|
|
70
88
|
description: "Use spawn with shell: false",
|
|
71
89
|
fix: (line) => line.replace(/shell\s*:\s*true/i, 'shell: false')
|
|
72
90
|
},
|
|
73
91
|
"dangerous-subprocess": {
|
|
74
|
-
description: "Use subprocess.run with list arguments",
|
|
75
|
-
fix: (line) =>
|
|
92
|
+
description: "Use subprocess.run with list arguments and shell=False",
|
|
93
|
+
fix: (line) => {
|
|
94
|
+
// Replace shell=True with shell=False
|
|
95
|
+
let fixed = line.replace(/shell\s*=\s*True/, 'shell=False');
|
|
96
|
+
// Replace string command with shlex.split() for safe list form
|
|
97
|
+
fixed = fixed.replace(
|
|
98
|
+
/subprocess\.(call|run|Popen)\s*\(\s*["'](.+?)["']/,
|
|
99
|
+
'subprocess.$1(shlex.split("$2")'
|
|
100
|
+
);
|
|
101
|
+
return fixed;
|
|
102
|
+
}
|
|
76
103
|
},
|
|
77
104
|
"dangerous-system-call": {
|
|
78
105
|
description: "Use subprocess.run instead of os.system",
|
|
79
|
-
fix: (line) =>
|
|
106
|
+
fix: (line) => {
|
|
107
|
+
const match = line.match(/os\.system\s*\(\s*(.+?)\s*\)/);
|
|
108
|
+
if (match) {
|
|
109
|
+
return line.replace(
|
|
110
|
+
/os\.system\s*\(\s*(.+?)\s*\)/,
|
|
111
|
+
'subprocess.run(shlex.split($1), shell=False)'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return '# SECURITY: Replace os.system() with subprocess.run(shlex.split(cmd), shell=False)\n# ' + line.trim();
|
|
115
|
+
}
|
|
80
116
|
},
|
|
81
117
|
"command-injection-exec": {
|
|
82
118
|
description: "Use exec.Command with separate arguments",
|
|
@@ -197,8 +233,11 @@ export const FIX_TEMPLATES = {
|
|
|
197
233
|
// INSECURE DESERIALIZATION
|
|
198
234
|
// ===========================================
|
|
199
235
|
"pickle": {
|
|
200
|
-
description: "Use JSON instead of pickle",
|
|
201
|
-
fix: (line) =>
|
|
236
|
+
description: "Use JSON instead of pickle for untrusted data",
|
|
237
|
+
fix: (line) => {
|
|
238
|
+
const fixed = line.replace(/pickle\.(load|loads)\s*\(/, 'json.$1(');
|
|
239
|
+
return fixed + ' # NOTE: data must be JSON-formatted';
|
|
240
|
+
}
|
|
202
241
|
},
|
|
203
242
|
"yaml-load": {
|
|
204
243
|
description: "Use yaml.safe_load()",
|
|
@@ -209,8 +248,8 @@ export const FIX_TEMPLATES = {
|
|
|
209
248
|
fix: (line) => line.replace(/marshal\.(load|loads)\s*\(/, 'json.$1(')
|
|
210
249
|
},
|
|
211
250
|
"shelve": {
|
|
212
|
-
description: "Use JSON or SQLite instead of shelve",
|
|
213
|
-
fix: (line) =>
|
|
251
|
+
description: "Use JSON or SQLite instead of shelve for safe storage",
|
|
252
|
+
fix: (line) => '# SECURITY: Replace shelve with json or sqlite3 for safe storage\n# ' + line.trim()
|
|
214
253
|
},
|
|
215
254
|
"node-serialize": {
|
|
216
255
|
description: "Use JSON.parse instead of node-serialize",
|
|
@@ -257,12 +296,12 @@ export const FIX_TEMPLATES = {
|
|
|
257
296
|
// PATH TRAVERSAL
|
|
258
297
|
// ===========================================
|
|
259
298
|
"path-traversal": {
|
|
260
|
-
description: "
|
|
299
|
+
description: "Resolve real path and validate prefix to prevent traversal",
|
|
261
300
|
fix: (line, lang) => {
|
|
262
|
-
if (lang === 'python') return line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.
|
|
263
|
-
if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.
|
|
264
|
-
if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File(
|
|
265
|
-
return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.
|
|
301
|
+
if (lang === 'python') return line.replace(/open\s*\(\s*(\w+)/, 'open(os.path.realpath($1) # TODO: validate path prefix');
|
|
302
|
+
if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Clean($1) // TODO: validate path prefix');
|
|
303
|
+
if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File($1).getCanonicalFile( // TODO: validate path prefix');
|
|
304
|
+
return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.resolve($1) // TODO: validate path prefix');
|
|
266
305
|
}
|
|
267
306
|
},
|
|
268
307
|
|
|
@@ -407,7 +446,14 @@ export const FIX_TEMPLATES = {
|
|
|
407
446
|
// ===========================================
|
|
408
447
|
"prototype-pollution": {
|
|
409
448
|
description: "Validate object keys before assignment",
|
|
410
|
-
fix: (line) =>
|
|
449
|
+
fix: (line) => {
|
|
450
|
+
// Only fix simple single-line assignments like: obj[key] = value
|
|
451
|
+
// Reject lines with multiple bracket accesses or chained assignments
|
|
452
|
+
if (/^\s*\w+\[\w+\]\s*=\s*[^[=]+$/.test(line)) {
|
|
453
|
+
return line.replace(/(\w+)\[(\w+)\]\s*=/, 'if (!["__proto__", "constructor", "prototype"].includes($2)) $1[$2] =');
|
|
454
|
+
}
|
|
455
|
+
return '// SECURITY: Validate key is not __proto__/constructor/prototype before assignment\n// ' + line.trim();
|
|
456
|
+
}
|
|
411
457
|
},
|
|
412
458
|
|
|
413
459
|
// ===========================================
|
|
@@ -443,7 +489,7 @@ export const FIX_TEMPLATES = {
|
|
|
443
489
|
// ===========================================
|
|
444
490
|
"helmet-missing": {
|
|
445
491
|
description: "Add helmet middleware for security headers",
|
|
446
|
-
fix: (line) => 'app.use(helmet())
|
|
492
|
+
fix: (line) => '// TODO: Add app.use(helmet()) after Express app initialization\n' + line
|
|
447
493
|
},
|
|
448
494
|
|
|
449
495
|
// ===========================================
|
|
@@ -495,7 +541,10 @@ export const FIX_TEMPLATES = {
|
|
|
495
541
|
},
|
|
496
542
|
"run-shell-form": {
|
|
497
543
|
description: "Use exec form for RUN commands",
|
|
498
|
-
fix: (line) => line.replace(/RUN\s+(.+)$/,
|
|
544
|
+
fix: (line) => line.replace(/RUN\s+(.+)$/, (_, cmd) => {
|
|
545
|
+
const escaped = cmd.replace(/"/g, '\\"');
|
|
546
|
+
return `RUN ["/bin/sh", "-c", "${escaped}"]`;
|
|
547
|
+
})
|
|
499
548
|
},
|
|
500
549
|
"sudo-in-dockerfile": {
|
|
501
550
|
description: "Avoid sudo in Dockerfile - use USER directive",
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
4
|
import { detectLanguage, runAnalyzer, generateFix } 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 fixSecuritySchema = {
|
|
7
10
|
file_path: z.string().describe("Path to the file to fix"),
|
|
@@ -47,24 +50,48 @@ export async function fixSecurity({ file_path, verbosity }) {
|
|
|
47
50
|
};
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
// Load project configuration
|
|
54
|
+
const config = loadConfig(file_path);
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
// Check file exclusion
|
|
57
|
+
if (shouldExcludeFile(file_path, config)) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: JSON.stringify({ file: file_path, message: "File excluded by configuration", fixes_applied: 0 }) }]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rawIssues = runAnalyzer(file_path);
|
|
64
|
+
|
|
65
|
+
if (rawIssues.error || !Array.isArray(rawIssues) || rawIssues.length === 0) {
|
|
53
66
|
return {
|
|
54
67
|
content: [{
|
|
55
68
|
type: "text",
|
|
56
69
|
text: JSON.stringify({
|
|
57
|
-
message:
|
|
58
|
-
details:
|
|
70
|
+
message: rawIssues.error ? "Error scanning file" : "No security issues found",
|
|
71
|
+
details: rawIssues
|
|
59
72
|
})
|
|
60
73
|
}]
|
|
61
74
|
};
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
// Cross-engine deduplication
|
|
78
|
+
const dedupedIssues = deduplicateFindings(rawIssues);
|
|
79
|
+
|
|
64
80
|
// Read and fix the file
|
|
65
81
|
const content = readFileSync(file_path, 'utf-8');
|
|
66
82
|
const lines = content.split('\n');
|
|
67
83
|
const language = detectLanguage(file_path);
|
|
84
|
+
|
|
85
|
+
// Context-aware filtering (suppress known module imports)
|
|
86
|
+
const contextFiltered = applyContextFilter(dedupedIssues, file_path, language);
|
|
87
|
+
|
|
88
|
+
// Framework-aware severity adjustment
|
|
89
|
+
const frameworks = detectFrameworks(file_path, language);
|
|
90
|
+
const frameworkAdjusted = applyFrameworkAdjustments(contextFiltered, frameworks);
|
|
91
|
+
|
|
92
|
+
// Apply .scannerrc configuration (rule suppression, severity/confidence thresholds)
|
|
93
|
+
const issues = applyConfig(frameworkAdjusted, file_path, config);
|
|
94
|
+
|
|
68
95
|
const fixes = [];
|
|
69
96
|
|
|
70
97
|
// Apply fixes (process in reverse order to preserve line numbers)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/tools/scan-diff.js
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { scanSecurity } from './scan-security.js';
|
|
6
|
+
|
|
7
|
+
export const scanDiffSchema = {
|
|
8
|
+
base_ref: z.string().optional().describe("Base git ref (default: HEAD~1)"),
|
|
9
|
+
target_ref: z.string().optional().describe("Target git ref (default: HEAD)"),
|
|
10
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level")
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Parse unified diff output to extract changed files and line ranges
|
|
14
|
+
function parseDiffOutput(diffOutput) {
|
|
15
|
+
const changes = new Map(); // filePath -> Set<lineNumber>
|
|
16
|
+
let currentFile = null;
|
|
17
|
+
|
|
18
|
+
for (const line of diffOutput.split('\n')) {
|
|
19
|
+
// Match diff header: +++ b/path/to/file
|
|
20
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
|
21
|
+
if (fileMatch) {
|
|
22
|
+
currentFile = fileMatch[1];
|
|
23
|
+
if (!changes.has(currentFile)) {
|
|
24
|
+
changes.set(currentFile, new Set());
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Match hunk header: @@ -old,count +new,count @@
|
|
30
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
31
|
+
if (hunkMatch && currentFile) {
|
|
32
|
+
const start = parseInt(hunkMatch[1], 10);
|
|
33
|
+
const count = parseInt(hunkMatch[2] || '1', 10);
|
|
34
|
+
const fileChanges = changes.get(currentFile);
|
|
35
|
+
for (let i = start; i < start + count; i++) {
|
|
36
|
+
fileChanges.add(i);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return changes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function scanDiff({ base_ref, target_ref, verbosity }) {
|
|
45
|
+
const base = base_ref || 'HEAD~1';
|
|
46
|
+
const target = target_ref || 'HEAD';
|
|
47
|
+
|
|
48
|
+
// Get diff output
|
|
49
|
+
let diffOutput;
|
|
50
|
+
try {
|
|
51
|
+
diffOutput = execFileSync('git', ['diff', '--unified=0', `${base}...${target}`], {
|
|
52
|
+
encoding: 'utf-8',
|
|
53
|
+
timeout: 30000,
|
|
54
|
+
maxBuffer: 10 * 1024 * 1024
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Try without three-dot notation (for uncommitted changes)
|
|
58
|
+
try {
|
|
59
|
+
diffOutput = execFileSync('git', ['diff', '--unified=0', base, target], {
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
timeout: 30000,
|
|
62
|
+
maxBuffer: 10 * 1024 * 1024
|
|
63
|
+
});
|
|
64
|
+
} catch (err2) {
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: JSON.stringify({ error: `Git diff failed: ${err2.message}` }) }]
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!diffOutput.trim()) {
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: JSON.stringify({ message: "No changes found between refs", base, target, issues_count: 0 }) }]
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse diff to get changed files and lines
|
|
78
|
+
const changes = parseDiffOutput(diffOutput);
|
|
79
|
+
const allIssues = [];
|
|
80
|
+
const scannedFiles = [];
|
|
81
|
+
|
|
82
|
+
// Scan each changed file
|
|
83
|
+
for (const [filePath, changedLines] of changes) {
|
|
84
|
+
if (!existsSync(filePath)) continue;
|
|
85
|
+
|
|
86
|
+
const result = await scanSecurity({ file_path: filePath, verbosity: 'full' });
|
|
87
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
88
|
+
|
|
89
|
+
if (parsed.issues && Array.isArray(parsed.issues)) {
|
|
90
|
+
// Filter to only issues on changed lines
|
|
91
|
+
const diffIssues = parsed.issues.filter(issue => {
|
|
92
|
+
const issueLine = (issue.line || 0) + 1; // convert 0-indexed to 1-indexed
|
|
93
|
+
return changedLines.has(issueLine);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
for (const issue of diffIssues) {
|
|
97
|
+
allIssues.push({ ...issue, file: filePath });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
scannedFiles.push(filePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Format based on verbosity
|
|
104
|
+
const level = verbosity || 'compact';
|
|
105
|
+
|
|
106
|
+
if (level === 'minimal') {
|
|
107
|
+
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
108
|
+
allIssues.forEach(i => bySeverity[i.severity] = (bySeverity[i.severity] || 0) + 1);
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
111
|
+
base, target,
|
|
112
|
+
files_scanned: scannedFiles.length,
|
|
113
|
+
total: allIssues.length,
|
|
114
|
+
critical: bySeverity.error,
|
|
115
|
+
warning: bySeverity.warning,
|
|
116
|
+
info: bySeverity.info,
|
|
117
|
+
message: allIssues.length > 0
|
|
118
|
+
? `Found ${allIssues.length} new issue(s) in changed code.`
|
|
119
|
+
: "No new security issues in changed code."
|
|
120
|
+
}) }]
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (level === 'compact') {
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
127
|
+
base, target,
|
|
128
|
+
files_scanned: scannedFiles.length,
|
|
129
|
+
issues_count: allIssues.length,
|
|
130
|
+
issues: allIssues.map(i => ({
|
|
131
|
+
file: i.file,
|
|
132
|
+
line: (i.line || 0) + 1,
|
|
133
|
+
ruleId: i.ruleId,
|
|
134
|
+
severity: i.severity,
|
|
135
|
+
message: i.message
|
|
136
|
+
}))
|
|
137
|
+
}, null, 2) }]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// full
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
144
|
+
base, target,
|
|
145
|
+
files_scanned: scannedFiles.length,
|
|
146
|
+
issues_count: allIssues.length,
|
|
147
|
+
issues: allIssues,
|
|
148
|
+
changed_files: Array.from(changes.keys())
|
|
149
|
+
}, null, 2) }]
|
|
150
|
+
};
|
|
151
|
+
}
|