agent-security-scanner-mcp 3.6.0 → 3.8.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/context.js ADDED
@@ -0,0 +1,289 @@
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
+ // Variable names that indicate non-security use of weak hashing (MD5/SHA1)
150
+ const NON_SECURITY_HASH_VARS = new Set([
151
+ 'checksum', 'digest', 'etag', 'e_tag', 'hash_value', 'file_hash',
152
+ 'content_hash', 'cache_key', 'fingerprint', 'hex_digest', 'hexdigest',
153
+ ]);
154
+
155
+ // Inline suppression comments
156
+ const NOSEC_PATTERN = /(?:\/\/|#|\/\*)\s*nosec\b/i;
157
+
158
+ // Test file path patterns
159
+ const TEST_FILE_PATTERNS = [
160
+ /[/\\]tests?[/\\]/i,
161
+ /[/\\]__tests__[/\\]/i,
162
+ /[/\\]spec[/\\]/i,
163
+ /[._](?:test|spec)\.[^.]+$/i,
164
+ /[/\\]test[-_]?files?[/\\]/i,
165
+ /[/\\]fixtures?[/\\]/i,
166
+ /[/\\]demo[/\\]/i,
167
+ ];
168
+
169
+ // Check if a file path looks like a test file
170
+ export function isTestFile(filePath) {
171
+ return TEST_FILE_PATTERNS.some(p => p.test(filePath));
172
+ }
173
+
174
+ // Check if a line has a nosec suppression comment
175
+ export function hasNosecComment(line) {
176
+ return NOSEC_PATTERN.test(line);
177
+ }
178
+
179
+ // Check if a variable name on a line suggests non-security hash usage
180
+ function isNonSecurityHashUsage(line) {
181
+ const lower = line.toLowerCase();
182
+ for (const varName of NON_SECURITY_HASH_VARS) {
183
+ if (lower.includes(varName)) return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ // Filter findings based on context awareness
189
+ export function applyContextFilter(findings, filePath, language) {
190
+ if (!Array.isArray(findings) || findings.length === 0) return findings;
191
+
192
+ let lines = [];
193
+ try {
194
+ if (existsSync(filePath)) {
195
+ lines = readFileSync(filePath, 'utf-8').split('\n');
196
+ }
197
+ } catch {
198
+ return findings;
199
+ }
200
+
201
+ const inTestFile = isTestFile(filePath);
202
+
203
+ return findings.filter(finding => {
204
+ const line = lines[finding.line] || '';
205
+ const ruleId = finding.ruleId?.toLowerCase() || '';
206
+
207
+ // Inline suppression: // nosec or # nosec
208
+ if (hasNosecComment(line)) {
209
+ return false;
210
+ }
211
+
212
+ // Variable-name heuristic: MD5/SHA1 used for checksums → downgrade to info
213
+ if ((ruleId.includes('md5') || ruleId.includes('sha1')) && isNonSecurityHashUsage(line)) {
214
+ finding.severity = 'info';
215
+ finding.contextNote = 'Non-security hash usage (checksum/digest/etag)';
216
+ }
217
+
218
+ // Test file heuristic: downgrade hardcoded secrets in test files to warning
219
+ if (inTestFile && (ruleId.includes('hardcoded') || ruleId.includes('secret') || ruleId.includes('password') || ruleId.includes('api-key'))) {
220
+ if (finding.severity === 'error') {
221
+ finding.severity = 'warning';
222
+ finding.contextNote = 'Hardcoded secret in test file';
223
+ }
224
+ }
225
+
226
+ // Import-only filter
227
+ if (!isImportOnly(line)) return true;
228
+
229
+ // Check if the module is known/safe
230
+ const moduleName = extractModuleName(line);
231
+ if (moduleName && isKnownModule(moduleName, language)) {
232
+ return false; // Suppress finding on known module import
233
+ }
234
+
235
+ return true;
236
+ });
237
+ }
238
+
239
+ // Framework/middleware detection patterns
240
+ const FRAMEWORK_PATTERNS = {
241
+ helmet: { pattern: /require\s*\(\s*['"]helmet['"]\s*\)|from\s+['"]helmet['"]|import\s+.*helmet/, languages: ['javascript', 'typescript'] },
242
+ dompurify: { pattern: /require\s*\(\s*['"](?:dompurify|isomorphic-dompurify)['"]\s*\)|from\s+['"](?:dompurify|isomorphic-dompurify)['"]|import\s+.*(?:dompurify|DOMPurify)/, languages: ['javascript', 'typescript'] },
243
+ csurf: { pattern: /require\s*\(\s*['"]csurf['"]\s*\)|from\s+['"]csurf['"]/, languages: ['javascript', 'typescript'] },
244
+ cors: { pattern: /require\s*\(\s*['"]cors['"]\s*\)|from\s+['"]cors['"]/, languages: ['javascript', 'typescript'] },
245
+ prisma: { pattern: /from\s+prisma|import\s+prisma|@prisma\/client/, languages: ['javascript', 'typescript', 'python'] },
246
+ bcrypt: { pattern: /import\s+bcrypt|from\s+bcrypt|require\s*\(\s*['"]bcryptjs?['"]\s*\)/, languages: ['javascript', 'typescript', 'python'] },
247
+ };
248
+
249
+ // Maps framework -> which rule categories it mitigates -> downgraded severity
250
+ const SEVERITY_DOWNGRADE = {
251
+ helmet: { mitigates: ['xss', 'innerhtml', 'outerhtml', 'document-write', 'cors-wildcard'], to: 'warning' },
252
+ dompurify: { mitigates: ['xss', 'innerhtml', 'outerhtml', 'dangerouslysetinnerhtml', 'insertadjacenthtml', 'document-write'], to: 'warning' },
253
+ csurf: { mitigates: ['csrf'], to: 'warning' },
254
+ cors: { mitigates: ['cors-wildcard'], to: 'info' },
255
+ prisma: { mitigates: ['sql-injection', 'nosql-injection', 'raw-query'], to: 'warning' },
256
+ bcrypt: { mitigates: ['md5', 'sha1', 'weak-hash', 'weak-cipher'], to: 'info' },
257
+ };
258
+
259
+ export function detectFrameworks(filePath, language) {
260
+ const detected = [];
261
+ try {
262
+ if (!existsSync(filePath)) return detected;
263
+ const content = readFileSync(filePath, 'utf-8');
264
+ for (const [name, config] of Object.entries(FRAMEWORK_PATTERNS)) {
265
+ if (config.languages.includes(language) && config.pattern.test(content)) {
266
+ detected.push(name);
267
+ }
268
+ }
269
+ } catch {
270
+ // Ignore read errors
271
+ }
272
+ return detected;
273
+ }
274
+
275
+ export function applyFrameworkAdjustments(findings, frameworks) {
276
+ if (!Array.isArray(findings) || findings.length === 0 || frameworks.length === 0) return findings;
277
+
278
+ return findings.map(finding => {
279
+ const ruleId = finding.ruleId?.toLowerCase() || '';
280
+ for (const fw of frameworks) {
281
+ const downgrade = SEVERITY_DOWNGRADE[fw];
282
+ if (!downgrade) continue;
283
+ if (downgrade.mitigates.some(m => ruleId.includes(m))) {
284
+ return { ...finding, severity: downgrade.to, frameworkMitigated: fw };
285
+ }
286
+ }
287
+ return finding;
288
+ });
289
+ }
@@ -0,0 +1,233 @@
1
+ // src/daemon-client.js — Node.js client managing daemon lifecycle and JSONL communication.
2
+ import { spawn } from 'child_process';
3
+ import { createInterface } from 'readline';
4
+ import { dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ let __dirname;
8
+ try {
9
+ __dirname = dirname(fileURLToPath(import.meta.url));
10
+ } catch {
11
+ __dirname = process.cwd();
12
+ }
13
+
14
+ const DAEMON_SCRIPT = join(__dirname, '..', 'daemon.py');
15
+ const READY_TIMEOUT = 15000; // 15s to wait for __ready__ signal
16
+ const REQUEST_TIMEOUT = 30000; // 30s per request
17
+ const MAX_RESTARTS = 3;
18
+ const RESTART_WINDOW = 60000; // 60s window for restart counting
19
+
20
+ let _reqCounter = 0;
21
+ function nextId() {
22
+ return `req-${++_reqCounter}`;
23
+ }
24
+
25
+ class DaemonClient {
26
+ constructor() {
27
+ this._proc = null;
28
+ this._rl = null;
29
+ this._pending = new Map(); // id -> { resolve, reject, timer }
30
+ this._dead = false; // permanently dead after too many restarts
31
+ this._restarts = []; // timestamps of recent restarts
32
+ this._starting = null; // promise while startup in progress
33
+ }
34
+
35
+ get isAvailable() {
36
+ return !this._dead;
37
+ }
38
+
39
+ async ensureRunning() {
40
+ if (this._dead) throw new Error('Daemon permanently unavailable');
41
+ if (this._proc && !this._proc.killed && this._proc.exitCode === null) return;
42
+ if (this._starting) return this._starting;
43
+
44
+ this._starting = this._spawn();
45
+ try {
46
+ await this._starting;
47
+ } finally {
48
+ this._starting = null;
49
+ }
50
+ }
51
+
52
+ _spawn() {
53
+ return new Promise((resolve, reject) => {
54
+ // Track restarts
55
+ const now = Date.now();
56
+ this._restarts = this._restarts.filter(t => now - t < RESTART_WINDOW);
57
+ if (this._restarts.length >= MAX_RESTARTS) {
58
+ this._dead = true;
59
+ reject(new Error('Daemon exceeded max restarts, falling back to sync'));
60
+ return;
61
+ }
62
+ this._restarts.push(now);
63
+
64
+ // Cleanup any previous process
65
+ this._cleanup();
66
+
67
+ const proc = spawn('python3', [DAEMON_SCRIPT], {
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
70
+ });
71
+
72
+ this._proc = proc;
73
+
74
+ // stderr goes to process.stderr for debug logging
75
+ proc.stderr.on('data', (chunk) => {
76
+ if (process.env.DAEMON_DEBUG) {
77
+ process.stderr.write(`[daemon] ${chunk}`);
78
+ }
79
+ });
80
+
81
+ // Handle process exit
82
+ proc.on('exit', (code, signal) => {
83
+ // Reject all pending requests
84
+ for (const [id, entry] of this._pending) {
85
+ clearTimeout(entry.timer);
86
+ entry.reject(new Error(`Daemon exited (code=${code}, signal=${signal})`));
87
+ }
88
+ this._pending.clear();
89
+ this._proc = null;
90
+ this._rl = null;
91
+ });
92
+
93
+ proc.on('error', (err) => {
94
+ this._dead = true;
95
+ reject(err);
96
+ });
97
+
98
+ // Read JSONL from stdout
99
+ const rl = createInterface({ input: proc.stdout });
100
+ this._rl = rl;
101
+
102
+ // Wait for __ready__ signal with timeout
103
+ const readyTimer = setTimeout(() => {
104
+ this._cleanup();
105
+ reject(new Error('Daemon startup timed out'));
106
+ }, READY_TIMEOUT);
107
+
108
+ let readyResolved = false;
109
+
110
+ rl.on('line', (line) => {
111
+ let msg;
112
+ try {
113
+ msg = JSON.parse(line);
114
+ } catch {
115
+ return; // skip non-JSON lines
116
+ }
117
+
118
+ // Handle ready signal
119
+ if (!readyResolved && msg.id === '__ready__') {
120
+ readyResolved = true;
121
+ clearTimeout(readyTimer);
122
+ resolve();
123
+ return;
124
+ }
125
+
126
+ // Route response to pending request
127
+ const id = msg.id;
128
+ if (id && this._pending.has(id)) {
129
+ const entry = this._pending.get(id);
130
+ this._pending.delete(id);
131
+ clearTimeout(entry.timer);
132
+ if (msg.success) {
133
+ entry.resolve(msg);
134
+ } else {
135
+ entry.reject(new Error(msg.error || 'Daemon request failed'));
136
+ }
137
+ }
138
+ });
139
+
140
+ rl.on('close', () => {
141
+ if (!readyResolved) {
142
+ clearTimeout(readyTimer);
143
+ reject(new Error('Daemon stdout closed before ready'));
144
+ }
145
+ });
146
+ });
147
+ }
148
+
149
+ _cleanup() {
150
+ if (this._rl) {
151
+ try { this._rl.close(); } catch { /* ignore */ }
152
+ this._rl = null;
153
+ }
154
+ if (this._proc) {
155
+ try { this._proc.kill(); } catch { /* ignore */ }
156
+ this._proc = null;
157
+ }
158
+ }
159
+
160
+ _send(obj) {
161
+ return new Promise((resolve, reject) => {
162
+ const id = obj.id || nextId();
163
+ obj.id = id;
164
+
165
+ const timer = setTimeout(() => {
166
+ this._pending.delete(id);
167
+ reject(new Error(`Daemon request timed out (id=${id})`));
168
+ }, REQUEST_TIMEOUT);
169
+
170
+ this._pending.set(id, { resolve, reject, timer });
171
+
172
+ try {
173
+ this._proc.stdin.write(JSON.stringify(obj) + '\n');
174
+ } catch (err) {
175
+ this._pending.delete(id);
176
+ clearTimeout(timer);
177
+ reject(err);
178
+ }
179
+ });
180
+ }
181
+
182
+ async analyze(filePath, engine = 'auto') {
183
+ await this.ensureRunning();
184
+ const resp = await this._send({
185
+ action: 'analyze',
186
+ file_path: filePath,
187
+ engine,
188
+ });
189
+ return resp.result;
190
+ }
191
+
192
+ async crossFileAnalyze(filePaths) {
193
+ await this.ensureRunning();
194
+ const resp = await this._send({
195
+ action: 'cross_file_analyze',
196
+ file_paths: filePaths,
197
+ });
198
+ return resp.result;
199
+ }
200
+
201
+ async health() {
202
+ await this.ensureRunning();
203
+ const resp = await this._send({ action: 'health' });
204
+ return resp.result;
205
+ }
206
+
207
+ async shutdown() {
208
+ if (!this._proc || this._proc.killed || this._proc.exitCode !== null) return;
209
+ try {
210
+ await this._send({ action: 'shutdown' });
211
+ } catch {
212
+ // ignore — process may already be gone
213
+ }
214
+ this._cleanup();
215
+ }
216
+ }
217
+
218
+ // Singleton instance
219
+ let _instance = null;
220
+
221
+ export function getDaemonClient() {
222
+ if (!_instance) {
223
+ _instance = new DaemonClient();
224
+ }
225
+ return _instance;
226
+ }
227
+
228
+ export async function shutdownDaemon() {
229
+ if (_instance) {
230
+ await _instance.shutdown();
231
+ _instance = null;
232
+ }
233
+ }
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
+ }