agent-security-scanner-mcp 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -3
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/index.js +191 -2
- package/package.json +15 -5
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/rules/openclaw.security.yaml +283 -0
- package/scripts/postinstall.js +25 -0
- package/skills/openclaw/SKILL.md +102 -0
- package/skills/security-scan-batch.md +107 -0
- package/skills/security-scanner.md +76 -0
- package/src/cli/init-hooks.js +164 -0
- package/src/cli/init.js +93 -0
- package/src/config.js +181 -0
- package/src/context.js +228 -0
- package/src/dedup.js +129 -0
- package/src/fix-patterns.js +66 -17
- package/src/tools/fix-security.js +31 -4
- package/src/tools/scan-diff.js +151 -0
- package/src/tools/scan-project.js +308 -0
- package/src/tools/scan-prompt.js +71 -1
- package/src/tools/scan-security.js +33 -5
- package/src/utils.js +76 -7
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/cli/init-hooks.js
|
|
2
|
+
// CLI command: init-hooks
|
|
3
|
+
// Installs Claude Code hooks for automatic security scanning on file write/edit.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
const SCANNER_HOOK_MARKER = 'agent-security-scanner-mcp';
|
|
9
|
+
|
|
10
|
+
function buildHooksConfig(withPromptGuard) {
|
|
11
|
+
const hooks = {
|
|
12
|
+
'post-tool-use': [
|
|
13
|
+
{
|
|
14
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
15
|
+
command: `npx agent-security-scanner-mcp scan-security "$TOOL_INPUT_FILE_PATH" --verbosity minimal`,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (withPromptGuard) {
|
|
21
|
+
hooks['pre-tool-use'] = [
|
|
22
|
+
{
|
|
23
|
+
matcher: 'Bash',
|
|
24
|
+
command: `npx agent-security-scanner-mcp scan-prompt "$TOOL_INPUT_COMMAND" --verbosity minimal`,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return hooks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function backupTimestamp() {
|
|
33
|
+
const d = new Date();
|
|
34
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
35
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseFlags(args) {
|
|
39
|
+
const flags = { dryRun: false, path: null, withPromptGuard: false };
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < args.length) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
if (arg === '--dry-run') flags.dryRun = true;
|
|
44
|
+
else if (arg === '--path' && i + 1 < args.length) flags.path = args[++i];
|
|
45
|
+
else if (arg === '--with-prompt-guard') flags.withPromptGuard = true;
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
return flags;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function containsScannerHook(hooksObj) {
|
|
52
|
+
if (!hooksObj || typeof hooksObj !== 'object') return false;
|
|
53
|
+
for (const eventHooks of Object.values(hooksObj)) {
|
|
54
|
+
if (!Array.isArray(eventHooks)) continue;
|
|
55
|
+
if (eventHooks.some(h => h.command && h.command.includes(SCANNER_HOOK_MARKER))) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeHooks(existingHooks, newHooks) {
|
|
63
|
+
const merged = { ...existingHooks };
|
|
64
|
+
|
|
65
|
+
for (const [event, hooks] of Object.entries(newHooks)) {
|
|
66
|
+
if (!merged[event]) {
|
|
67
|
+
merged[event] = hooks;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Filter out existing scanner hooks for this event
|
|
72
|
+
const nonScanner = merged[event].filter(h =>
|
|
73
|
+
!h.command || !h.command.includes(SCANNER_HOOK_MARKER)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
merged[event] = [...nonScanner, ...hooks];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runInitHooks(args) {
|
|
83
|
+
const flags = parseFlags(args);
|
|
84
|
+
|
|
85
|
+
console.log('\n Agentic Security - Claude Code Hooks Setup\n');
|
|
86
|
+
|
|
87
|
+
const settingsDir = flags.path || join(process.cwd(), '.claude');
|
|
88
|
+
const settingsPath = join(settingsDir, 'settings.json');
|
|
89
|
+
|
|
90
|
+
console.log(` Settings: ${settingsPath}`);
|
|
91
|
+
console.log(` Prompt guard: ${flags.withPromptGuard ? 'enabled' : 'disabled (use --with-prompt-guard to enable)'}`);
|
|
92
|
+
console.log('');
|
|
93
|
+
|
|
94
|
+
const newHooks = buildHooksConfig(flags.withPromptGuard);
|
|
95
|
+
|
|
96
|
+
// Read existing settings
|
|
97
|
+
let existing = {};
|
|
98
|
+
let fileExisted = false;
|
|
99
|
+
if (existsSync(settingsPath)) {
|
|
100
|
+
fileExisted = true;
|
|
101
|
+
try {
|
|
102
|
+
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(` ERROR: Invalid JSON in ${settingsPath}`);
|
|
105
|
+
console.error(` ${e.message}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (containsScannerHook(existing.hooks)) {
|
|
111
|
+
console.log(' Scanner hooks already configured. Updating...');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Merge hooks non-destructively
|
|
115
|
+
const mergedHooks = mergeHooks(existing.hooks || {}, newHooks);
|
|
116
|
+
const merged = { ...existing, hooks: mergedHooks };
|
|
117
|
+
const output = JSON.stringify(merged, null, 2) + '\n';
|
|
118
|
+
|
|
119
|
+
if (flags.dryRun) {
|
|
120
|
+
console.log(' [dry-run] Would write:\n');
|
|
121
|
+
console.log(' ' + output.split('\n').join('\n '));
|
|
122
|
+
console.log(' No changes made.\n');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!existsSync(settingsDir)) {
|
|
127
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
128
|
+
console.log(` Created directory: ${settingsDir}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (fileExisted) {
|
|
132
|
+
const backupPath = `${settingsPath}.bak-${backupTimestamp()}`;
|
|
133
|
+
copyFileSync(settingsPath, backupPath);
|
|
134
|
+
console.log(` Backup: ${backupPath}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
writeFileSync(settingsPath, output);
|
|
138
|
+
console.log(` Wrote: ${settingsPath}\n`);
|
|
139
|
+
|
|
140
|
+
console.log(' Hooks installed:');
|
|
141
|
+
for (const [event, hooks] of Object.entries(newHooks)) {
|
|
142
|
+
for (const hook of hooks) {
|
|
143
|
+
console.log(` - [${event}] Matcher: ${hook.matcher}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('\n Security scanning is now automatic for file writes and edits.');
|
|
148
|
+
console.log(' Restart Claude Code for hooks to take effect.\n');
|
|
149
|
+
|
|
150
|
+
if (!existsSync(join(process.cwd(), '.scannerrc.yaml')) &&
|
|
151
|
+
!existsSync(join(process.cwd(), '.scannerrc.yml')) &&
|
|
152
|
+
!existsSync(join(process.cwd(), '.scannerrc.json'))) {
|
|
153
|
+
console.log(' Tip: Create a .scannerrc.yaml to customize scanning:');
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(' version: 1');
|
|
156
|
+
console.log(' suppress:');
|
|
157
|
+
console.log(' - rule: "insecure-random"');
|
|
158
|
+
console.log(' exclude:');
|
|
159
|
+
console.log(' - "node_modules/**"');
|
|
160
|
+
console.log(' - "dist/**"');
|
|
161
|
+
console.log(' severity_threshold: "warning"');
|
|
162
|
+
console.log('');
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/cli/init.js
CHANGED
|
@@ -73,6 +73,12 @@ const CLIENT_CONFIGS = {
|
|
|
73
73
|
configKey: 'mcpServers',
|
|
74
74
|
configPath: () => join(vscodeBase(), 'Code', 'User', 'globalStorage', 'sourcegraph.cody-ai', 'mcp_settings.json'),
|
|
75
75
|
buildEntry: () => ({ ...MCP_SERVER_ENTRY })
|
|
76
|
+
},
|
|
77
|
+
'openclaw': {
|
|
78
|
+
name: 'OpenClaw',
|
|
79
|
+
isSkillBased: true, // OpenClaw uses skills, not MCP config
|
|
80
|
+
skillPath: () => join(homedir(), '.openclaw', 'workspace', 'skills', 'security-scanner'),
|
|
81
|
+
configPath: () => join(homedir(), '.openclaw', 'workspace', 'skills', 'security-scanner', 'SKILL.md')
|
|
76
82
|
}
|
|
77
83
|
};
|
|
78
84
|
|
|
@@ -150,6 +156,87 @@ function printInitUsage() {
|
|
|
150
156
|
console.log(' npx agent-security-scanner-mcp init cline --force --name my-scanner\n');
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
// Special installer for OpenClaw (skill-based)
|
|
160
|
+
async function installOpenClawSkill(client, flags) {
|
|
161
|
+
const skillDir = client.skillPath();
|
|
162
|
+
const skillFile = client.configPath();
|
|
163
|
+
|
|
164
|
+
// Find the source skill file (bundled with the package)
|
|
165
|
+
const __dirname = dirname(new URL(import.meta.url).pathname);
|
|
166
|
+
const sourceSkill = join(__dirname, '..', '..', 'skills', 'openclaw', 'SKILL.md');
|
|
167
|
+
|
|
168
|
+
console.log(`\n Client: ${client.name}`);
|
|
169
|
+
console.log(` Skill: ${skillDir}`);
|
|
170
|
+
console.log(` OS: ${platform()} (${process.arch})\n`);
|
|
171
|
+
|
|
172
|
+
// Check if OpenClaw workspace exists
|
|
173
|
+
const openclawDir = join(homedir(), '.openclaw');
|
|
174
|
+
if (!existsSync(openclawDir)) {
|
|
175
|
+
console.log(` OpenClaw not found at ${openclawDir}`);
|
|
176
|
+
console.log(` Please install OpenClaw first: https://openclaw.ai\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check if source skill exists
|
|
181
|
+
if (!existsSync(sourceSkill)) {
|
|
182
|
+
console.error(` ERROR: Skill source not found at ${sourceSkill}`);
|
|
183
|
+
console.error(` This may be a packaging issue. Please reinstall the package.\n`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check if skill already exists
|
|
188
|
+
if (existsSync(skillFile)) {
|
|
189
|
+
const existing = readFileSync(skillFile, 'utf-8');
|
|
190
|
+
const source = readFileSync(sourceSkill, 'utf-8');
|
|
191
|
+
if (existing === source) {
|
|
192
|
+
console.log(` Security scanner skill is already installed (identical).`);
|
|
193
|
+
console.log(` Nothing to do.\n`);
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(` Security scanner skill exists but differs.`);
|
|
198
|
+
if (!flags.force) {
|
|
199
|
+
if (flags.yes) {
|
|
200
|
+
console.log(` Skipping (use --force to overwrite).\n`);
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
204
|
+
const answer = await new Promise((resolve) => {
|
|
205
|
+
rl.question(' Overwrite? (y/N): ', (a) => { rl.close(); resolve(a); });
|
|
206
|
+
});
|
|
207
|
+
if (answer.toLowerCase() !== 'y') {
|
|
208
|
+
console.log(' Aborted.\n');
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Dry-run mode
|
|
215
|
+
if (flags.dryRun) {
|
|
216
|
+
console.log(` [dry-run] Would create directory: ${skillDir}`);
|
|
217
|
+
console.log(` [dry-run] Would copy skill from: ${sourceSkill}`);
|
|
218
|
+
console.log(` [dry-run] Would write to: ${skillFile}`);
|
|
219
|
+
console.log(` No changes made.\n`);
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Create skill directory
|
|
224
|
+
if (!existsSync(skillDir)) {
|
|
225
|
+
mkdirSync(skillDir, { recursive: true });
|
|
226
|
+
console.log(` Created directory: ${skillDir}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Copy skill file
|
|
230
|
+
copyFileSync(sourceSkill, skillFile);
|
|
231
|
+
console.log(` Installed skill: ${skillFile}`);
|
|
232
|
+
|
|
233
|
+
console.log(`\n OpenClaw security scanner skill installed successfully!`);
|
|
234
|
+
console.log(`\n Usage in OpenClaw:`);
|
|
235
|
+
console.log(` - The skill will be auto-discovered by OpenClaw`);
|
|
236
|
+
console.log(` - Use /security-scanner to invoke it`);
|
|
237
|
+
console.log(` - Or ask: "scan this prompt for security issues"\n`);
|
|
238
|
+
}
|
|
239
|
+
|
|
153
240
|
export async function runInit(args) {
|
|
154
241
|
const flags = parseInitFlags(args);
|
|
155
242
|
let clientName = flags.client;
|
|
@@ -171,6 +258,12 @@ export async function runInit(args) {
|
|
|
171
258
|
process.exit(1);
|
|
172
259
|
}
|
|
173
260
|
|
|
261
|
+
// Special handling for OpenClaw (skill-based, not MCP config)
|
|
262
|
+
if (client.isSkillBased) {
|
|
263
|
+
await installOpenClawSkill(client, flags);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
174
267
|
const configPath = flags.path || client.configPath();
|
|
175
268
|
const serverName = flags.name;
|
|
176
269
|
const entry = client.buildEntry();
|
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
|
+
}
|