agent-security-scanner-mcp 3.3.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 +224 -2
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/index.js +104 -4
- package/package.json +10 -3
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/scripts/postinstall.js +25 -0
- package/src/cli/init-hooks.js +164 -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-security.js +33 -5
- package/src/utils.js +76 -7
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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] || '';
|