agent-security-scanner-mcp 3.7.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/README.md +42 -8
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/daemon.py +179 -0
- package/index.js +279 -3
- package/package.json +19 -5
- package/packages/npm-bloom.json +1 -0
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/requirements.txt +1 -0
- package/rules/prompt-injection.security.yaml +273 -41
- package/scripts/postinstall.js +60 -0
- package/skills/openclaw/SKILL.md +102 -0
- package/skills/security-review.md +139 -0
- package/skills/security-scan-batch.md +107 -0
- package/skills/security-scanner.md +76 -0
- package/src/cli/doctor.js +29 -1
- package/src/cli/init.js +93 -0
- package/src/cli/report.js +444 -0
- package/src/config.js +247 -0
- package/src/context.js +289 -0
- package/src/daemon-client.js +233 -0
- package/src/dedup.js +129 -0
- package/src/fix-patterns.js +76 -19
- package/src/history.js +159 -0
- package/src/tools/check-package.js +36 -12
- package/src/tools/fix-security.js +32 -5
- package/src/tools/import-resolver.js +249 -0
- package/src/tools/project-context.js +365 -0
- package/src/tools/scan-action.js +489 -0
- package/src/tools/scan-mcp.js +588 -0
- package/src/tools/scan-project.js +16 -4
- package/src/tools/scan-prompt.js +292 -527
- package/src/tools/scan-security.js +37 -6
- package/src/typosquat.js +210 -0
- package/src/utils.js +215 -8
- package/templates/gitlab-ci-security.yml +225 -0
- package/templates/pre-commit-hook.sh +233 -0
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
|
+
}
|