getdoorman 1.0.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/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/doorman.js +444 -0
- package/package.json +74 -0
- package/src/ai-fixer.js +559 -0
- package/src/ast-scanner.js +434 -0
- package/src/auth.js +149 -0
- package/src/baseline.js +48 -0
- package/src/compliance.js +539 -0
- package/src/config.js +466 -0
- package/src/custom-rules.js +32 -0
- package/src/dashboard.js +202 -0
- package/src/detector.js +142 -0
- package/src/fix-engine.js +48 -0
- package/src/fix-registry-extra.js +95 -0
- package/src/fix-registry-go-rust.js +77 -0
- package/src/fix-registry-java-csharp.js +77 -0
- package/src/fix-registry-js.js +99 -0
- package/src/fix-registry-mcp-ai.js +57 -0
- package/src/fix-registry-python.js +87 -0
- package/src/fixer-ruby-php.js +608 -0
- package/src/fixer.js +2113 -0
- package/src/hooks.js +115 -0
- package/src/ignore.js +176 -0
- package/src/index.js +384 -0
- package/src/metrics.js +126 -0
- package/src/monorepo.js +65 -0
- package/src/presets.js +54 -0
- package/src/reporter.js +975 -0
- package/src/rule-worker.js +36 -0
- package/src/rules/ast-rules.js +756 -0
- package/src/rules/bugs/accessibility.js +235 -0
- package/src/rules/bugs/ai-codegen-fixable.js +172 -0
- package/src/rules/bugs/ai-codegen.js +365 -0
- package/src/rules/bugs/code-smell-bugs.js +247 -0
- package/src/rules/bugs/crypto-bugs.js +195 -0
- package/src/rules/bugs/docker-bugs.js +158 -0
- package/src/rules/bugs/general.js +361 -0
- package/src/rules/bugs/go-bugs.js +279 -0
- package/src/rules/bugs/index.js +73 -0
- package/src/rules/bugs/js-api.js +257 -0
- package/src/rules/bugs/js-array-object.js +210 -0
- package/src/rules/bugs/js-async-fixable.js +223 -0
- package/src/rules/bugs/js-async.js +211 -0
- package/src/rules/bugs/js-closure-scope.js +182 -0
- package/src/rules/bugs/js-database.js +203 -0
- package/src/rules/bugs/js-error-handling.js +148 -0
- package/src/rules/bugs/js-logic.js +261 -0
- package/src/rules/bugs/js-memory.js +214 -0
- package/src/rules/bugs/js-node.js +361 -0
- package/src/rules/bugs/js-react.js +373 -0
- package/src/rules/bugs/js-regex.js +200 -0
- package/src/rules/bugs/js-state.js +272 -0
- package/src/rules/bugs/js-type-coercion.js +318 -0
- package/src/rules/bugs/nextjs-bugs.js +242 -0
- package/src/rules/bugs/nextjs-fixable.js +120 -0
- package/src/rules/bugs/node-fixable.js +178 -0
- package/src/rules/bugs/python-advanced.js +245 -0
- package/src/rules/bugs/python-fixable.js +98 -0
- package/src/rules/bugs/python.js +284 -0
- package/src/rules/bugs/react-fixable.js +207 -0
- package/src/rules/bugs/ruby-bugs.js +182 -0
- package/src/rules/bugs/shell-bugs.js +181 -0
- package/src/rules/bugs/silent-failures.js +261 -0
- package/src/rules/bugs/ts-bugs.js +235 -0
- package/src/rules/bugs/unused-vars.js +65 -0
- package/src/rules/compliance/accessibility-ext.js +468 -0
- package/src/rules/compliance/education.js +322 -0
- package/src/rules/compliance/financial.js +421 -0
- package/src/rules/compliance/frameworks.js +507 -0
- package/src/rules/compliance/healthcare.js +520 -0
- package/src/rules/compliance/index.js +2714 -0
- package/src/rules/compliance/regional-eu.js +480 -0
- package/src/rules/compliance/regional-international.js +903 -0
- package/src/rules/cost/index.js +1993 -0
- package/src/rules/data/index.js +2503 -0
- package/src/rules/dependencies/index.js +1684 -0
- package/src/rules/deployment/index.js +2050 -0
- package/src/rules/index.js +71 -0
- package/src/rules/infrastructure/index.js +3048 -0
- package/src/rules/performance/index.js +3455 -0
- package/src/rules/quality/index.js +3175 -0
- package/src/rules/reliability/index.js +3040 -0
- package/src/rules/scope-rules.js +815 -0
- package/src/rules/security/ai-api.js +1177 -0
- package/src/rules/security/auth.js +1328 -0
- package/src/rules/security/cors.js +127 -0
- package/src/rules/security/crypto.js +527 -0
- package/src/rules/security/csharp.js +862 -0
- package/src/rules/security/csrf.js +193 -0
- package/src/rules/security/dart.js +835 -0
- package/src/rules/security/deserialization.js +291 -0
- package/src/rules/security/file-upload.js +187 -0
- package/src/rules/security/go.js +850 -0
- package/src/rules/security/headers.js +235 -0
- package/src/rules/security/index.js +65 -0
- package/src/rules/security/injection.js +1639 -0
- package/src/rules/security/mcp-server.js +71 -0
- package/src/rules/security/misconfiguration.js +660 -0
- package/src/rules/security/oauth-jwt.js +329 -0
- package/src/rules/security/path-traversal.js +295 -0
- package/src/rules/security/php.js +1054 -0
- package/src/rules/security/prototype-pollution.js +283 -0
- package/src/rules/security/rate-limiting.js +208 -0
- package/src/rules/security/ruby.js +1061 -0
- package/src/rules/security/rust.js +693 -0
- package/src/rules/security/secrets.js +747 -0
- package/src/rules/security/shell.js +647 -0
- package/src/rules/security/ssrf.js +298 -0
- package/src/rules/security/supply-chain-advanced.js +393 -0
- package/src/rules/security/supply-chain.js +734 -0
- package/src/rules/security/swift.js +835 -0
- package/src/rules/security/taint.js +27 -0
- package/src/rules/security/xss.js +520 -0
- package/src/scan-cache.js +71 -0
- package/src/scanner.js +710 -0
- package/src/scope-analyzer.js +685 -0
- package/src/share.js +88 -0
- package/src/taint.js +300 -0
- package/src/telemetry.js +183 -0
- package/src/tracer.js +190 -0
- package/src/upload.js +35 -0
- package/src/worker.js +31 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "getdoorman",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-config security scanner for AI-assisted development. 2000+ rules, 11 languages, 4 detection engines.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./fixer": "./src/fixer.js",
|
|
9
|
+
"./reporter": "./src/reporter.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"getdoorman": "./bin/doorman.js"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node bin/doorman.js",
|
|
17
|
+
"test": "node --test test/*.test.js",
|
|
18
|
+
"update-stats": "node scripts/update-stats.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"security",
|
|
22
|
+
"scanner",
|
|
23
|
+
"linter",
|
|
24
|
+
"sast",
|
|
25
|
+
"vulnerability",
|
|
26
|
+
"compliance",
|
|
27
|
+
"code-quality",
|
|
28
|
+
"performance",
|
|
29
|
+
"ai",
|
|
30
|
+
"mcp",
|
|
31
|
+
"cli",
|
|
32
|
+
"zero-config",
|
|
33
|
+
"auto-fix"
|
|
34
|
+
],
|
|
35
|
+
"author": "",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/tiago520/VoiceAI.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/tiago520/VoiceAI#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/tiago520/VoiceAI/issues"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src/",
|
|
47
|
+
"bin/",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"chalk": "^5.3.0",
|
|
53
|
+
"commander": "^12.1.0",
|
|
54
|
+
"glob": "^11.0.0",
|
|
55
|
+
"minimatch": "^9.0.0",
|
|
56
|
+
"ora": "^8.1.0"
|
|
57
|
+
},
|
|
58
|
+
"optionalDependencies": {
|
|
59
|
+
"tree-sitter": "^0.22.4",
|
|
60
|
+
"tree-sitter-javascript": "^0.23.0",
|
|
61
|
+
"tree-sitter-python": "^0.23.0",
|
|
62
|
+
"tree-sitter-go": "^0.23.0",
|
|
63
|
+
"tree-sitter-ruby": "^0.23.0",
|
|
64
|
+
"tree-sitter-php": "^0.23.0"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=18.0.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"better-sqlite3": "^12.8.0",
|
|
71
|
+
"cors": "^2.8.6",
|
|
72
|
+
"express": "^5.2.1"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/ai-fixer.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-fixer.js — AI-powered vulnerability explanation and fix generation.
|
|
3
|
+
*
|
|
4
|
+
* PRO feature that uses the Claude API (or OpenAI as fallback) to:
|
|
5
|
+
* - Explain vulnerabilities in plain English
|
|
6
|
+
* - Generate context-aware, style-matching code fixes
|
|
7
|
+
* - Batch-process multiple findings with progress reporting
|
|
8
|
+
*
|
|
9
|
+
* Requires an API key via:
|
|
10
|
+
* 1. ANTHROPIC_API_KEY environment variable
|
|
11
|
+
* 2. OPENAI_API_KEY environment variable (fallback)
|
|
12
|
+
* 3. .doormanrc.json { "apiKey": "..." }
|
|
13
|
+
*
|
|
14
|
+
* Uses native fetch() — no external dependencies. Requires Node 18+.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync } from 'fs';
|
|
18
|
+
import { resolve } from 'path';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Constants
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
|
|
26
|
+
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
|
|
27
|
+
|
|
28
|
+
const ANTHROPIC_MODEL = 'claude-sonnet-4-6';
|
|
29
|
+
const OPENAI_MODEL = 'gpt-4o';
|
|
30
|
+
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
32
|
+
const RATE_LIMIT_BACKOFF_MS = 2_000;
|
|
33
|
+
const BATCH_SIZE = 5;
|
|
34
|
+
const CONTEXT_LINES = 20; // lines above and below the finding to include
|
|
35
|
+
|
|
36
|
+
// Claude Sonnet pricing (per million tokens)
|
|
37
|
+
const PRICING = {
|
|
38
|
+
anthropic: { input: 3, output: 15 },
|
|
39
|
+
openai: { input: 5, output: 15 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// API key resolution
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the API key and provider from environment variables or config file.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} [cwd='.'] - Working directory to search for .doormanrc.json
|
|
50
|
+
* @returns {{ provider: 'anthropic'|'openai', apiKey: string } | null}
|
|
51
|
+
*/
|
|
52
|
+
function resolveApiKey(cwd = '.') {
|
|
53
|
+
// 1. ANTHROPIC_API_KEY env var
|
|
54
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
55
|
+
return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. OPENAI_API_KEY env var (fallback)
|
|
59
|
+
if (process.env.OPENAI_API_KEY) {
|
|
60
|
+
return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. .doormanrc.json
|
|
64
|
+
const rcPath = resolve(cwd, '.doormanrc.json');
|
|
65
|
+
if (existsSync(rcPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const rc = JSON.parse(readFileSync(rcPath, 'utf-8'));
|
|
68
|
+
if (rc.apiKey) {
|
|
69
|
+
// Infer provider from key prefix
|
|
70
|
+
const provider = rc.apiKey.startsWith('sk-ant-') ? 'anthropic' : 'openai';
|
|
71
|
+
return { provider, apiKey: rc.apiKey };
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Malformed config — ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Print a helpful message when no API key is found and throw.
|
|
83
|
+
*/
|
|
84
|
+
function requireApiKey(cwd) {
|
|
85
|
+
const resolved = resolveApiKey(cwd);
|
|
86
|
+
if (!resolved) {
|
|
87
|
+
console.log('');
|
|
88
|
+
console.log(
|
|
89
|
+
chalk.yellow(
|
|
90
|
+
' AI fixes require an API key. Set ANTHROPIC_API_KEY=your-key or add it to .doormanrc.json',
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
chalk.gray(' Example: export ANTHROPIC_API_KEY=sk-ant-...'),
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
chalk.gray(' Or add { "apiKey": "sk-ant-..." } to .doormanrc.json'),
|
|
98
|
+
);
|
|
99
|
+
console.log('');
|
|
100
|
+
throw new Error('Missing API key for AI fixes');
|
|
101
|
+
}
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Low-level API helpers
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Call the Anthropic Messages API.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} apiKey
|
|
113
|
+
* @param {string} systemPrompt
|
|
114
|
+
* @param {string} userMessage
|
|
115
|
+
* @returns {Promise<{ text: string, inputTokens: number, outputTokens: number }>}
|
|
116
|
+
*/
|
|
117
|
+
async function callAnthropic(apiKey, systemPrompt, userMessage) {
|
|
118
|
+
const body = {
|
|
119
|
+
model: ANTHROPIC_MODEL,
|
|
120
|
+
max_tokens: 2048,
|
|
121
|
+
system: systemPrompt,
|
|
122
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const response = await fetchWithRetry(ANTHROPIC_API_URL, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
'x-api-key': apiKey,
|
|
130
|
+
'anthropic-version': '2023-06-01',
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const data = await response.json();
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const msg = data?.error?.message || JSON.stringify(data);
|
|
139
|
+
throw new Error(`Anthropic API error (${response.status}): ${msg}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const text = data.content?.[0]?.text || '';
|
|
143
|
+
const inputTokens = data.usage?.input_tokens || 0;
|
|
144
|
+
const outputTokens = data.usage?.output_tokens || 0;
|
|
145
|
+
|
|
146
|
+
return { text, inputTokens, outputTokens };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Call the OpenAI Chat Completions API.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} apiKey
|
|
153
|
+
* @param {string} systemPrompt
|
|
154
|
+
* @param {string} userMessage
|
|
155
|
+
* @returns {Promise<{ text: string, inputTokens: number, outputTokens: number }>}
|
|
156
|
+
*/
|
|
157
|
+
async function callOpenAI(apiKey, systemPrompt, userMessage) {
|
|
158
|
+
const body = {
|
|
159
|
+
model: OPENAI_MODEL,
|
|
160
|
+
max_tokens: 2048,
|
|
161
|
+
messages: [
|
|
162
|
+
{ role: 'system', content: systemPrompt },
|
|
163
|
+
{ role: 'user', content: userMessage },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const response = await fetchWithRetry(OPENAI_API_URL, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
Authorization: `Bearer ${apiKey}`,
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify(body),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const msg = data?.error?.message || JSON.stringify(data);
|
|
180
|
+
throw new Error(`OpenAI API error (${response.status}): ${msg}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const text = data.choices?.[0]?.message?.content || '';
|
|
184
|
+
const inputTokens = data.usage?.prompt_tokens || 0;
|
|
185
|
+
const outputTokens = data.usage?.completion_tokens || 0;
|
|
186
|
+
|
|
187
|
+
return { text, inputTokens, outputTokens };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Unified API call dispatcher.
|
|
192
|
+
*
|
|
193
|
+
* @param {'anthropic'|'openai'} provider
|
|
194
|
+
* @param {string} apiKey
|
|
195
|
+
* @param {string} systemPrompt
|
|
196
|
+
* @param {string} userMessage
|
|
197
|
+
* @returns {Promise<{ text: string, inputTokens: number, outputTokens: number }>}
|
|
198
|
+
*/
|
|
199
|
+
async function callLLM(provider, apiKey, systemPrompt, userMessage) {
|
|
200
|
+
if (provider === 'anthropic') {
|
|
201
|
+
return callAnthropic(apiKey, systemPrompt, userMessage);
|
|
202
|
+
}
|
|
203
|
+
return callOpenAI(apiKey, systemPrompt, userMessage);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Fetch with a timeout and a single retry on HTTP 429 (rate limit).
|
|
208
|
+
*
|
|
209
|
+
* @param {string} url
|
|
210
|
+
* @param {RequestInit} options
|
|
211
|
+
* @returns {Promise<Response>}
|
|
212
|
+
*/
|
|
213
|
+
async function fetchWithRetry(url, options) {
|
|
214
|
+
const attempt = async () => {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
return res;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
if (err.name === 'AbortError') {
|
|
225
|
+
throw new Error(`API request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const first = await attempt();
|
|
232
|
+
|
|
233
|
+
// Retry once on 429 with backoff
|
|
234
|
+
if (first.status === 429) {
|
|
235
|
+
await sleep(RATE_LIMIT_BACKOFF_MS);
|
|
236
|
+
return attempt();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return first;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Simple async sleep helper. */
|
|
243
|
+
function sleep(ms) {
|
|
244
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Code context extraction
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extract lines around a finding for context.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} fileContent - Full file content.
|
|
255
|
+
* @param {number} [line] - 1-based line number of the finding.
|
|
256
|
+
* @param {number} [radius] - Number of lines above and below to include.
|
|
257
|
+
* @returns {string} The surrounding code snippet with line numbers.
|
|
258
|
+
*/
|
|
259
|
+
function extractContext(fileContent, line, radius = CONTEXT_LINES) {
|
|
260
|
+
if (!fileContent) return '';
|
|
261
|
+
const lines = fileContent.split('\n');
|
|
262
|
+
|
|
263
|
+
if (!line || line < 1) {
|
|
264
|
+
// No line info — return first 40 lines (or entire file if shorter)
|
|
265
|
+
return lines.slice(0, radius * 2).map((l, i) => `${i + 1}: ${l}`).join('\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const start = Math.max(0, line - 1 - radius);
|
|
269
|
+
const end = Math.min(lines.length, line - 1 + radius + 1);
|
|
270
|
+
return lines
|
|
271
|
+
.slice(start, end)
|
|
272
|
+
.map((l, i) => {
|
|
273
|
+
const lineNum = start + i + 1;
|
|
274
|
+
const marker = lineNum === line ? '>>>' : ' ';
|
|
275
|
+
return `${marker} ${lineNum}: ${l}`;
|
|
276
|
+
})
|
|
277
|
+
.join('\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Cost tracking
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/** Mutable token counters for the current session. */
|
|
285
|
+
const tokenUsage = { input: 0, output: 0 };
|
|
286
|
+
|
|
287
|
+
function trackTokens(inputTokens, outputTokens) {
|
|
288
|
+
tokenUsage.input += inputTokens;
|
|
289
|
+
tokenUsage.output += outputTokens;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function resetTokenUsage() {
|
|
293
|
+
tokenUsage.input = 0;
|
|
294
|
+
tokenUsage.output = 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Calculate estimated cost and print a summary.
|
|
299
|
+
*
|
|
300
|
+
* @param {'anthropic'|'openai'} provider
|
|
301
|
+
*/
|
|
302
|
+
function printCostSummary(provider) {
|
|
303
|
+
const pricing = PRICING[provider] || PRICING.anthropic;
|
|
304
|
+
const inputCost = (tokenUsage.input / 1_000_000) * pricing.input;
|
|
305
|
+
const outputCost = (tokenUsage.output / 1_000_000) * pricing.output;
|
|
306
|
+
const totalCost = inputCost + outputCost;
|
|
307
|
+
|
|
308
|
+
console.log('');
|
|
309
|
+
console.log(
|
|
310
|
+
chalk.gray(
|
|
311
|
+
` AI fixes cost: ~$${totalCost.toFixed(2)} (${tokenUsage.input} input + ${tokenUsage.output} output tokens)`,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Prompt templates
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
const EXPLAIN_SYSTEM_PROMPT = `You are a security expert explaining vulnerabilities to non-technical people.
|
|
321
|
+
Be clear, specific, and avoid jargon. Use analogies when helpful.
|
|
322
|
+
Always respond with valid JSON in exactly this format:
|
|
323
|
+
{
|
|
324
|
+
"explanation": "A 2-4 sentence plain-English explanation of the vulnerability.",
|
|
325
|
+
"risk": "critical|high|medium|low",
|
|
326
|
+
"tldr": "A single sentence summary."
|
|
327
|
+
}`;
|
|
328
|
+
|
|
329
|
+
const FIX_SYSTEM_PROMPT = `You are a senior security engineer generating minimal, targeted code fixes.
|
|
330
|
+
Rules:
|
|
331
|
+
- Match the existing code style exactly (indentation, quotes, semicolons, etc.).
|
|
332
|
+
- Return ONLY the minimal change needed — do not refactor unrelated code.
|
|
333
|
+
- Always respond with valid JSON in exactly this format:
|
|
334
|
+
{
|
|
335
|
+
"old": "the exact lines to replace (copy from the source)",
|
|
336
|
+
"new": "the replacement lines",
|
|
337
|
+
"explanation": "1-2 sentences explaining what the fix does and why."
|
|
338
|
+
}`;
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Public API
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Explain a vulnerability in plain English for non-developers.
|
|
346
|
+
*
|
|
347
|
+
* @param {object} finding - A Doorman finding object.
|
|
348
|
+
* @param {string} fileContent - Full content of the file containing the vulnerability.
|
|
349
|
+
* @param {object} [options]
|
|
350
|
+
* @param {string} [options.cwd='.'] - Working directory (for API key resolution).
|
|
351
|
+
* @returns {Promise<{ explanation: string, risk: string, tldr: string }>}
|
|
352
|
+
*/
|
|
353
|
+
export async function explainVulnerability(finding, fileContent, options = {}) {
|
|
354
|
+
const { provider, apiKey } = requireApiKey(options.cwd || '.');
|
|
355
|
+
|
|
356
|
+
const context = extractContext(fileContent, finding.line);
|
|
357
|
+
|
|
358
|
+
const userMessage = `## Vulnerability
|
|
359
|
+
Title: ${finding.title || 'Unknown'}
|
|
360
|
+
Severity: ${finding.severity || 'unknown'}
|
|
361
|
+
Rule: ${finding.ruleId || 'N/A'}
|
|
362
|
+
File: ${finding.file || 'unknown'}
|
|
363
|
+
Line: ${finding.line || 'unknown'}
|
|
364
|
+
|
|
365
|
+
## Code Context
|
|
366
|
+
\`\`\`
|
|
367
|
+
${context}
|
|
368
|
+
\`\`\`
|
|
369
|
+
|
|
370
|
+
Explain this security vulnerability to a non-technical person. What could go wrong? How bad is it? What should they do?`;
|
|
371
|
+
|
|
372
|
+
const { text, inputTokens, outputTokens } = await callLLM(
|
|
373
|
+
provider,
|
|
374
|
+
apiKey,
|
|
375
|
+
EXPLAIN_SYSTEM_PROMPT,
|
|
376
|
+
userMessage,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
trackTokens(inputTokens, outputTokens);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const parsed = JSON.parse(text);
|
|
383
|
+
return {
|
|
384
|
+
explanation: parsed.explanation || text,
|
|
385
|
+
risk: parsed.risk || finding.severity || 'medium',
|
|
386
|
+
tldr: parsed.tldr || '',
|
|
387
|
+
};
|
|
388
|
+
} catch {
|
|
389
|
+
// If the model didn't return valid JSON, wrap the raw text
|
|
390
|
+
return {
|
|
391
|
+
explanation: text,
|
|
392
|
+
risk: finding.severity || 'medium',
|
|
393
|
+
tldr: '',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Generate a minimal, style-matching fix for a vulnerability.
|
|
400
|
+
*
|
|
401
|
+
* @param {object} finding - A Doorman finding object.
|
|
402
|
+
* @param {string} fileContent - Full content of the file containing the vulnerability.
|
|
403
|
+
* @param {object} [options]
|
|
404
|
+
* @param {string} [options.cwd='.'] - Working directory (for API key resolution).
|
|
405
|
+
* @returns {Promise<{ fix: { type: string, old: string, new: string, tier: number }, explanation: string }>}
|
|
406
|
+
*/
|
|
407
|
+
export async function generateFix(finding, fileContent, options = {}) {
|
|
408
|
+
const { provider, apiKey } = requireApiKey(options.cwd || '.');
|
|
409
|
+
|
|
410
|
+
const userMessage = `## Vulnerability
|
|
411
|
+
Title: ${finding.title || 'Unknown'}
|
|
412
|
+
Severity: ${finding.severity || 'unknown'}
|
|
413
|
+
Rule: ${finding.ruleId || 'N/A'}
|
|
414
|
+
File: ${finding.file || 'unknown'}
|
|
415
|
+
Line: ${finding.line || 'unknown'}
|
|
416
|
+
Description: ${finding.description || ''}
|
|
417
|
+
|
|
418
|
+
## Full File Content
|
|
419
|
+
\`\`\`
|
|
420
|
+
${fileContent}
|
|
421
|
+
\`\`\`
|
|
422
|
+
|
|
423
|
+
Generate a minimal fix for this vulnerability. Match the existing code style. Return only the changed lines as a diff.`;
|
|
424
|
+
|
|
425
|
+
const { text, inputTokens, outputTokens } = await callLLM(
|
|
426
|
+
provider,
|
|
427
|
+
apiKey,
|
|
428
|
+
FIX_SYSTEM_PROMPT,
|
|
429
|
+
userMessage,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
trackTokens(inputTokens, outputTokens);
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const parsed = JSON.parse(text);
|
|
436
|
+
return {
|
|
437
|
+
fix: {
|
|
438
|
+
type: 'replace',
|
|
439
|
+
old: parsed.old || '',
|
|
440
|
+
new: parsed.new || '',
|
|
441
|
+
tier: 2, // AI fixes always require review
|
|
442
|
+
},
|
|
443
|
+
explanation: parsed.explanation || '',
|
|
444
|
+
};
|
|
445
|
+
} catch {
|
|
446
|
+
// Best-effort extraction if JSON parsing fails
|
|
447
|
+
return {
|
|
448
|
+
fix: {
|
|
449
|
+
type: 'replace',
|
|
450
|
+
old: '',
|
|
451
|
+
new: text,
|
|
452
|
+
tier: 2,
|
|
453
|
+
},
|
|
454
|
+
explanation: 'AI returned a non-structured response. Please review manually.',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Batch-process multiple findings: explain each vulnerability and generate a fix.
|
|
461
|
+
*
|
|
462
|
+
* Processes findings in batches of {@link BATCH_SIZE} to respect rate limits.
|
|
463
|
+
* Prints progress to stdout.
|
|
464
|
+
*
|
|
465
|
+
* @param {object[]} findings - Array of Doorman finding objects.
|
|
466
|
+
* @param {Map<string, string>|object} files - Map (or plain object) of filePath -> fileContent.
|
|
467
|
+
* @param {object} [options]
|
|
468
|
+
* @param {string} [options.cwd='.'] - Working directory.
|
|
469
|
+
* @param {boolean} [options.explainOnly] - Only explain, skip fix generation.
|
|
470
|
+
* @param {boolean} [options.silent] - Suppress progress output.
|
|
471
|
+
* @returns {Promise<Array<{ finding: object, explanation: object|null, fix: object|null }>>}
|
|
472
|
+
*/
|
|
473
|
+
export async function aiFixAll(findings, files, options = {}) {
|
|
474
|
+
const { provider } = requireApiKey(options.cwd || '.');
|
|
475
|
+
const silent = options.silent || false;
|
|
476
|
+
const explainOnly = options.explainOnly || false;
|
|
477
|
+
|
|
478
|
+
resetTokenUsage();
|
|
479
|
+
|
|
480
|
+
const total = findings.length;
|
|
481
|
+
const results = [];
|
|
482
|
+
|
|
483
|
+
for (let i = 0; i < total; i += BATCH_SIZE) {
|
|
484
|
+
const batch = findings.slice(i, i + BATCH_SIZE);
|
|
485
|
+
|
|
486
|
+
const batchPromises = batch.map(async (finding, batchIdx) => {
|
|
487
|
+
const idx = i + batchIdx + 1;
|
|
488
|
+
|
|
489
|
+
if (!silent) {
|
|
490
|
+
console.log(chalk.gray(` Generating AI fix ${idx}/${total}...`));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Resolve file content
|
|
494
|
+
const filePath = finding.file || '';
|
|
495
|
+
let fileContent = '';
|
|
496
|
+
if (files instanceof Map) {
|
|
497
|
+
fileContent = files.get(filePath) || '';
|
|
498
|
+
} else if (files && typeof files === 'object') {
|
|
499
|
+
fileContent = files[filePath] || '';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// If we don't have the content in the map, try reading from disk
|
|
503
|
+
if (!fileContent && filePath) {
|
|
504
|
+
const fullPath = resolve(options.cwd || '.', filePath);
|
|
505
|
+
if (existsSync(fullPath)) {
|
|
506
|
+
try {
|
|
507
|
+
fileContent = readFileSync(fullPath, 'utf-8');
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (!silent) {
|
|
510
|
+
console.log(chalk.yellow(` Warning: Could not read ${filePath}: ${err.message}`));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let explanation = null;
|
|
517
|
+
let fix = null;
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
explanation = await explainVulnerability(finding, fileContent, options);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (!silent) {
|
|
523
|
+
console.log(chalk.red(` Failed to explain finding ${idx}: ${err.message}`));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!explainOnly) {
|
|
528
|
+
try {
|
|
529
|
+
const fixResult = await generateFix(finding, fileContent, options);
|
|
530
|
+
fix = fixResult.fix;
|
|
531
|
+
// Merge the fix explanation if the explain step failed
|
|
532
|
+
if (!explanation && fixResult.explanation) {
|
|
533
|
+
explanation = { explanation: fixResult.explanation, risk: finding.severity || 'medium', tldr: '' };
|
|
534
|
+
}
|
|
535
|
+
} catch (err) {
|
|
536
|
+
if (!silent) {
|
|
537
|
+
console.log(chalk.red(` Failed to generate fix for finding ${idx}: ${err.message}`));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { finding, explanation, fix };
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const batchResults = await Promise.all(batchPromises);
|
|
546
|
+
results.push(...batchResults);
|
|
547
|
+
|
|
548
|
+
// Small delay between batches to be kind to rate limits (skip after last batch)
|
|
549
|
+
if (i + BATCH_SIZE < total) {
|
|
550
|
+
await sleep(500);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!silent) {
|
|
555
|
+
printCostSummary(provider);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return results;
|
|
559
|
+
}
|