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/src/config.js ADDED
@@ -0,0 +1,181 @@
1
+ // .scannerrc configuration loading and filtering.
2
+ // Supports YAML (.scannerrc.yaml/.yml) and JSON (.scannerrc.json) project configs.
3
+
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { dirname, join, resolve, sep } from 'path';
6
+ import { execFileSync } from 'child_process';
7
+
8
+ const DEFAULT_CONFIG = {
9
+ version: 1,
10
+ suppress: [],
11
+ exclude: ['node_modules/**', 'vendor/**', 'dist/**', '**/*.min.js'],
12
+ severity_threshold: 'info',
13
+ confidence_threshold: 'LOW',
14
+ };
15
+
16
+ const SEVERITY_ORDER = { info: 0, warning: 1, error: 2 };
17
+ const CONFIDENCE_ORDER = { LOW: 0, MEDIUM: 1, HIGH: 2 };
18
+
19
+ // Simple glob-to-regex converter (no external dependency)
20
+ function globToRegex(pattern) {
21
+ let regex = '';
22
+ let i = 0;
23
+ while (i < pattern.length) {
24
+ const c = pattern[i];
25
+ if (c === '*') {
26
+ if (pattern[i + 1] === '*') {
27
+ if (pattern[i + 2] === '/') {
28
+ regex += '(?:.+/)?';
29
+ i += 3;
30
+ continue;
31
+ }
32
+ regex += '.*';
33
+ i += 2;
34
+ continue;
35
+ }
36
+ regex += '[^/]*';
37
+ } else if (c === '?') {
38
+ regex += '[^/]';
39
+ } else if (c === '{') {
40
+ regex += '(?:';
41
+ } else if (c === '}') {
42
+ regex += ')';
43
+ } else if (c === ',') {
44
+ regex += '|';
45
+ } else if ('.+^$|()[]\\'.includes(c)) {
46
+ regex += '\\' + c;
47
+ } else {
48
+ regex += c;
49
+ }
50
+ i++;
51
+ }
52
+ return new RegExp('^' + regex + '$');
53
+ }
54
+
55
+ export function matchGlob(filePath, pattern) {
56
+ // Normalize path separators
57
+ const normalized = filePath.replace(/\\/g, '/');
58
+ const re = globToRegex(pattern);
59
+ // Test against both full path and basename
60
+ return re.test(normalized) || re.test(normalized.split('/').pop());
61
+ }
62
+
63
+ // Walk up from filePath to find config file
64
+ function findConfigFile(startPath) {
65
+ const names = ['.scannerrc.yaml', '.scannerrc.yml', '.scannerrc.json'];
66
+ let dir = resolve(dirname(startPath));
67
+ const root = resolve('/');
68
+
69
+ for (let i = 0; i < 50; i++) {
70
+ for (const name of names) {
71
+ const candidate = join(dir, name);
72
+ if (existsSync(candidate)) return candidate;
73
+ }
74
+ const parent = dirname(dir);
75
+ if (parent === dir || dir === root) break;
76
+ dir = parent;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function parseYaml(filePath) {
82
+ try {
83
+ const result = execFileSync('python3', [
84
+ '-c',
85
+ 'import yaml,json,sys; print(json.dumps(yaml.safe_load(open(sys.argv[1]))))',
86
+ filePath,
87
+ ], { encoding: 'utf-8', timeout: 5000 });
88
+ return JSON.parse(result.trim());
89
+ } catch {
90
+ // Fallback: try simple key-value parsing for basic configs
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function loadConfig(filePath) {
96
+ const configFile = findConfigFile(filePath);
97
+ if (!configFile) return { ...DEFAULT_CONFIG };
98
+
99
+ try {
100
+ let parsed;
101
+ if (configFile.endsWith('.json')) {
102
+ parsed = JSON.parse(readFileSync(configFile, 'utf-8'));
103
+ } else {
104
+ parsed = parseYaml(configFile);
105
+ }
106
+
107
+ if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_CONFIG };
108
+
109
+ return {
110
+ version: parsed.version || DEFAULT_CONFIG.version,
111
+ suppress: Array.isArray(parsed.suppress) ? parsed.suppress : DEFAULT_CONFIG.suppress,
112
+ exclude: Array.isArray(parsed.exclude) ? parsed.exclude : DEFAULT_CONFIG.exclude,
113
+ severity_threshold: parsed.severity_threshold || DEFAULT_CONFIG.severity_threshold,
114
+ confidence_threshold: parsed.confidence_threshold || DEFAULT_CONFIG.confidence_threshold,
115
+ };
116
+ } catch {
117
+ return { ...DEFAULT_CONFIG };
118
+ }
119
+ }
120
+
121
+ export function shouldExcludeFile(filePath, config) {
122
+ if (!config.exclude || config.exclude.length === 0) return false;
123
+ const normalized = filePath.replace(/\\/g, '/');
124
+ return config.exclude.some(pattern => matchGlob(normalized, pattern));
125
+ }
126
+
127
+ export function shouldSuppressRule(ruleId, filePath, config) {
128
+ if (!config.suppress || config.suppress.length === 0) return false;
129
+
130
+ for (const entry of config.suppress) {
131
+ const rule = typeof entry === 'string' ? entry : entry.rule;
132
+ if (!rule) continue;
133
+
134
+ // Check if rule pattern matches
135
+ const ruleMatches = matchGlob(ruleId, rule);
136
+ if (!ruleMatches) continue;
137
+
138
+ // Check path restriction if present
139
+ if (entry.paths && Array.isArray(entry.paths)) {
140
+ const normalized = filePath.replace(/\\/g, '/');
141
+ const pathMatches = entry.paths.some(p => matchGlob(normalized, p));
142
+ if (!pathMatches) continue;
143
+ }
144
+
145
+ return true;
146
+ }
147
+
148
+ return false;
149
+ }
150
+
151
+ export function meetsSeverityThreshold(severity, config) {
152
+ const threshold = config.severity_threshold || 'info';
153
+ const severityLevel = SEVERITY_ORDER[severity] ?? 0;
154
+ const thresholdLevel = SEVERITY_ORDER[threshold] ?? 0;
155
+ return severityLevel >= thresholdLevel;
156
+ }
157
+
158
+ export function meetsConfidenceThreshold(confidence, config) {
159
+ const threshold = config.confidence_threshold || 'LOW';
160
+ const confidenceLevel = CONFIDENCE_ORDER[confidence] ?? 0;
161
+ const thresholdLevel = CONFIDENCE_ORDER[threshold] ?? 0;
162
+ return confidenceLevel >= thresholdLevel;
163
+ }
164
+
165
+ export function applyConfig(findings, filePath, config) {
166
+ if (!Array.isArray(findings)) return findings;
167
+ if (!config) return findings;
168
+
169
+ return findings.filter(finding => {
170
+ // Check rule suppression
171
+ if (shouldSuppressRule(finding.ruleId, filePath, config)) return false;
172
+
173
+ // Check severity threshold
174
+ if (!meetsSeverityThreshold(finding.severity, config)) return false;
175
+
176
+ // Check confidence threshold
177
+ if (!meetsConfidenceThreshold(finding.confidence || 'MEDIUM', config)) return false;
178
+
179
+ return true;
180
+ });
181
+ }
package/src/context.js ADDED
@@ -0,0 +1,228 @@
1
+ // Context-aware filtering to reduce false positives.
2
+ // Suppresses findings on import-only lines for known standard/popular modules.
3
+
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { dirname, join } from 'path';
6
+
7
+ // Known safe standard library and popular modules per language
8
+ const KNOWN_MODULES = {
9
+ javascript: new Set([
10
+ // Node.js builtins
11
+ 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram',
12
+ 'dns', 'events', 'fs', 'http', 'http2', 'https', 'net', 'os',
13
+ 'path', 'perf_hooks', 'process', 'querystring', 'readline', 'stream',
14
+ 'string_decoder', 'timers', 'tls', 'tty', 'url', 'util', 'v8',
15
+ 'vm', 'worker_threads', 'zlib',
16
+ // Popular frameworks/libraries
17
+ 'express', 'koa', 'fastify', 'hapi', 'next', 'nuxt',
18
+ 'react', 'react-dom', 'vue', 'angular', 'svelte',
19
+ 'lodash', 'underscore', 'ramda',
20
+ 'axios', 'node-fetch', 'got', 'superagent',
21
+ 'moment', 'dayjs', 'date-fns', 'luxon',
22
+ 'winston', 'morgan', 'pino', 'bunyan',
23
+ 'helmet', 'cors', 'body-parser', 'cookie-parser', 'compression',
24
+ 'passport', 'jsonwebtoken', 'bcrypt', 'bcryptjs',
25
+ 'jest', 'mocha', 'chai', 'vitest', 'sinon', 'tape',
26
+ 'typescript', 'webpack', 'vite', 'esbuild', 'rollup', 'parcel',
27
+ 'mysql', 'mysql2', 'pg', 'mongodb', 'mongoose', 'redis', 'ioredis',
28
+ 'sequelize', 'knex', 'prisma', 'typeorm', 'drizzle-orm',
29
+ 'zod', 'joi', 'yup', 'ajv',
30
+ 'dotenv', 'config', 'commander', 'yargs',
31
+ 'chalk', 'debug', 'uuid', 'nanoid',
32
+ 'socket.io', 'ws',
33
+ ]),
34
+ typescript: new Set([
35
+ // Same as JavaScript - TS shares the same ecosystem
36
+ 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram',
37
+ 'dns', 'events', 'fs', 'http', 'http2', 'https', 'net', 'os',
38
+ 'path', 'process', 'querystring', 'readline', 'stream', 'tls',
39
+ 'url', 'util', 'worker_threads', 'zlib',
40
+ 'express', 'koa', 'fastify', 'next', 'nuxt',
41
+ 'react', 'react-dom', 'vue', 'angular', 'svelte',
42
+ 'lodash', 'axios', 'node-fetch',
43
+ 'helmet', 'cors', 'body-parser',
44
+ 'jest', 'mocha', 'vitest',
45
+ 'typescript', 'webpack', 'vite', 'esbuild',
46
+ 'mysql', 'mysql2', 'pg', 'mongodb', 'mongoose', 'redis',
47
+ 'sequelize', 'knex', 'prisma', 'typeorm',
48
+ 'zod', 'joi',
49
+ ]),
50
+ python: new Set([
51
+ // Standard library
52
+ 'os', 'sys', 'json', 'math', 'datetime', 'collections', 're',
53
+ 'pathlib', 'typing', 'abc', 'io', 'subprocess', 'shutil',
54
+ 'hashlib', 'hmac', 'secrets', 'sqlite3', 'csv', 'xml',
55
+ 'urllib', 'http', 'socket', 'ssl', 'email', 'logging',
56
+ 'unittest', 'argparse', 'configparser', 'functools', 'itertools',
57
+ 'contextlib', 'dataclasses', 'enum', 'struct', 'copy', 'pprint',
58
+ 'textwrap', 'string', 'codecs', 'base64', 'binascii',
59
+ 'threading', 'multiprocessing', 'asyncio', 'concurrent',
60
+ 'pickle', 'shelve', 'marshal', 'dbm',
61
+ 'tempfile', 'glob', 'fnmatch', 'stat',
62
+ 'time', 'calendar', 'locale', 'gettext',
63
+ 'random', 'statistics',
64
+ // Popular packages
65
+ 'pytest', 'mock', 'coverage',
66
+ 'flask', 'django', 'fastapi', 'starlette', 'uvicorn', 'gunicorn',
67
+ 'requests', 'httpx', 'aiohttp', 'urllib3',
68
+ 'sqlalchemy', 'alembic', 'psycopg2', 'pymongo',
69
+ 'celery', 'redis', 'boto3', 'botocore',
70
+ 'numpy', 'pandas', 'scipy', 'matplotlib',
71
+ 'pydantic', 'marshmallow', 'attrs',
72
+ 'click', 'typer', 'rich',
73
+ 'yaml', 'toml', 'dotenv',
74
+ ]),
75
+ ruby: new Set([
76
+ 'rails', 'sinatra', 'rack', 'puma', 'unicorn',
77
+ 'bundler', 'rake', 'rspec', 'minitest',
78
+ 'activerecord', 'activesupport', 'actionpack',
79
+ 'devise', 'pundit', 'cancancan',
80
+ 'json', 'yaml', 'csv', 'net/http', 'uri', 'openssl',
81
+ 'fileutils', 'pathname', 'tempfile', 'logger',
82
+ ]),
83
+ go: new Set([
84
+ 'fmt', 'os', 'io', 'net', 'net/http', 'encoding/json',
85
+ 'encoding/xml', 'crypto', 'crypto/tls', 'database/sql',
86
+ 'sync', 'context', 'errors', 'strings', 'strconv',
87
+ 'path', 'path/filepath', 'log', 'testing', 'time',
88
+ 'math', 'sort', 'regexp', 'reflect', 'bufio',
89
+ ]),
90
+ };
91
+
92
+ // Patterns that identify import-only lines (no actual code execution)
93
+ const IMPORT_ONLY_PATTERNS = [
94
+ // JS/TS require
95
+ /^\s*(const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?\s*$/,
96
+ /^\s*(const|let|var)\s+\{[^}]+\}\s*=\s*require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?\s*$/,
97
+ // JS/TS import
98
+ /^\s*import\s+.*\s+from\s+['"][^'"]+['"]\s*;?\s*$/,
99
+ /^\s*import\s+['"][^'"]+['"]\s*;?\s*$/,
100
+ /^\s*import\s+\w+\s*$/,
101
+ // Python import
102
+ /^\s*import\s+[a-zA-Z_][\w.]*\s*(,\s*[a-zA-Z_][\w.]*)*\s*$/,
103
+ /^\s*from\s+[a-zA-Z_][\w.]*\s+import\s+/,
104
+ // Ruby require
105
+ /^\s*require\s+['"][^'"]+['"]\s*$/,
106
+ /^\s*require_relative\s+['"][^'"]+['"]\s*$/,
107
+ // Go import (single line)
108
+ /^\s*"[a-zA-Z_][\w/.]*"\s*$/,
109
+ ];
110
+
111
+ export function isImportOnly(line) {
112
+ let trimmed = line.trim();
113
+ if (!trimmed) return false;
114
+ // Strip trailing single-line comments (JS/Python/Ruby)
115
+ trimmed = trimmed.replace(/\s*\/\/.*$/, '').replace(/\s*#(?!!).*$/, '').trim();
116
+ if (!trimmed) return false;
117
+ return IMPORT_ONLY_PATTERNS.some(p => p.test(trimmed));
118
+ }
119
+
120
+ export function isKnownModule(moduleName, language) {
121
+ const modules = KNOWN_MODULES[language];
122
+ if (!modules) return false;
123
+ // Handle scoped packages (@org/pkg -> check full name)
124
+ // Handle subpath imports (child_process -> child_process)
125
+ const baseName = moduleName.split('/')[0];
126
+ return modules.has(moduleName) || modules.has(baseName);
127
+ }
128
+
129
+ // Extract module name from a line of code
130
+ function extractModuleName(line) {
131
+ // JS/TS: require("module") or require('module')
132
+ const requireMatch = line.match(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
133
+ if (requireMatch) return requireMatch[1];
134
+
135
+ // JS/TS: import ... from "module"
136
+ const importFromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
137
+ if (importFromMatch) return importFromMatch[1];
138
+
139
+ // Python: import module or from module import ...
140
+ const pyImportMatch = line.match(/^\s*import\s+([a-zA-Z_][\w]*)/);
141
+ if (pyImportMatch) return pyImportMatch[1];
142
+
143
+ const pyFromMatch = line.match(/^\s*from\s+([a-zA-Z_][\w]*)/);
144
+ if (pyFromMatch) return pyFromMatch[1];
145
+
146
+ return null;
147
+ }
148
+
149
+ // Filter findings based on context awareness
150
+ export function applyContextFilter(findings, filePath, language) {
151
+ if (!Array.isArray(findings) || findings.length === 0) return findings;
152
+
153
+ let lines = [];
154
+ try {
155
+ if (existsSync(filePath)) {
156
+ lines = readFileSync(filePath, 'utf-8').split('\n');
157
+ }
158
+ } catch {
159
+ return findings;
160
+ }
161
+
162
+ return findings.filter(finding => {
163
+ const line = lines[finding.line] || '';
164
+
165
+ // Only filter import-only lines
166
+ if (!isImportOnly(line)) return true;
167
+
168
+ // Check if the module is known/safe
169
+ const moduleName = extractModuleName(line);
170
+ if (moduleName && isKnownModule(moduleName, language)) {
171
+ return false; // Suppress finding on known module import
172
+ }
173
+
174
+ return true;
175
+ });
176
+ }
177
+
178
+ // Framework/middleware detection patterns
179
+ const FRAMEWORK_PATTERNS = {
180
+ helmet: { pattern: /require\s*\(\s*['"]helmet['"]\s*\)|from\s+['"]helmet['"]|import\s+.*helmet/, languages: ['javascript', 'typescript'] },
181
+ dompurify: { pattern: /require\s*\(\s*['"](?:dompurify|isomorphic-dompurify)['"]\s*\)|from\s+['"](?:dompurify|isomorphic-dompurify)['"]|import\s+.*(?:dompurify|DOMPurify)/, languages: ['javascript', 'typescript'] },
182
+ csurf: { pattern: /require\s*\(\s*['"]csurf['"]\s*\)|from\s+['"]csurf['"]/, languages: ['javascript', 'typescript'] },
183
+ cors: { pattern: /require\s*\(\s*['"]cors['"]\s*\)|from\s+['"]cors['"]/, languages: ['javascript', 'typescript'] },
184
+ prisma: { pattern: /from\s+prisma|import\s+prisma|@prisma\/client/, languages: ['javascript', 'typescript', 'python'] },
185
+ bcrypt: { pattern: /import\s+bcrypt|from\s+bcrypt|require\s*\(\s*['"]bcryptjs?['"]\s*\)/, languages: ['javascript', 'typescript', 'python'] },
186
+ };
187
+
188
+ // Maps framework -> which rule categories it mitigates -> downgraded severity
189
+ const SEVERITY_DOWNGRADE = {
190
+ helmet: { mitigates: ['xss', 'innerhtml', 'outerhtml', 'document-write', 'cors-wildcard'], to: 'warning' },
191
+ dompurify: { mitigates: ['xss', 'innerhtml', 'outerhtml', 'dangerouslysetinnerhtml', 'insertadjacenthtml', 'document-write'], to: 'warning' },
192
+ csurf: { mitigates: ['csrf'], to: 'warning' },
193
+ cors: { mitigates: ['cors-wildcard'], to: 'info' },
194
+ prisma: { mitigates: ['sql-injection', 'nosql-injection', 'raw-query'], to: 'warning' },
195
+ bcrypt: { mitigates: ['md5', 'sha1', 'weak-hash', 'weak-cipher'], to: 'info' },
196
+ };
197
+
198
+ export function detectFrameworks(filePath, language) {
199
+ const detected = [];
200
+ try {
201
+ if (!existsSync(filePath)) return detected;
202
+ const content = readFileSync(filePath, 'utf-8');
203
+ for (const [name, config] of Object.entries(FRAMEWORK_PATTERNS)) {
204
+ if (config.languages.includes(language) && config.pattern.test(content)) {
205
+ detected.push(name);
206
+ }
207
+ }
208
+ } catch {
209
+ // Ignore read errors
210
+ }
211
+ return detected;
212
+ }
213
+
214
+ export function applyFrameworkAdjustments(findings, frameworks) {
215
+ if (!Array.isArray(findings) || findings.length === 0 || frameworks.length === 0) return findings;
216
+
217
+ return findings.map(finding => {
218
+ const ruleId = finding.ruleId?.toLowerCase() || '';
219
+ for (const fw of frameworks) {
220
+ const downgrade = SEVERITY_DOWNGRADE[fw];
221
+ if (!downgrade) continue;
222
+ if (downgrade.mitigates.some(m => ruleId.includes(m))) {
223
+ return { ...finding, severity: downgrade.to, frameworkMitigated: fw };
224
+ }
225
+ }
226
+ return finding;
227
+ });
228
+ }
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
+ }
@@ -63,20 +63,56 @@ export const FIX_TEMPLATES = {
63
63
  // COMMAND INJECTION
64
64
  // ===========================================
65
65
  "child-process-exec": {
66
- description: "Use execFile() or spawn() with shell: false",
67
- fix: (line) => line.replace(/\bexec\s*\(/, 'execFile(')
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) => line.replace(/subprocess\.(call|run|Popen)\s*\(\s*["'](.+?)["']\s*,\s*shell\s*=\s*True/, 'subprocess.$1(["$2".split()], shell=False')
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) => line.replace(/os\.system\s*\(/, 'subprocess.run([')
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) => line.replace(/pickle\.(load|loads)\s*\(/, 'json.$1(')
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) => line.replace(/shelve\.open\s*\(/, 'json.load(open(')
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: "Sanitize file paths and use basename",
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.basename($1)');
263
- if (lang === 'go') return line.replace(/os\.Open\s*\(\s*(\w+)/, 'os.Open(filepath.Base($1)');
264
- if (lang === 'java') return line.replace(/new File\s*\(\s*(\w+)/, 'new File(new File($1).getName()');
265
- return line.replace(/readFileSync\s*\(\s*(\w+)/, 'readFileSync(path.basename($1)');
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) => line.replace(/(\w+)\[(\w+)\]\s*=/, 'if (!["__proto__", "constructor", "prototype"].includes($2)) $1[$2] =')
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()); // Add security headers\n' + line
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+(.+)$/, 'RUN ["/bin/sh", "-c", "$1"]')
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",