securevibe 0.1.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Hardcoded-secret detection (doc 03). Text-level so it covers .env, config,
3
+ * and any file — not just parseable source. Provider-format + high-entropy.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ // Ordered most-specific → most-generic. One finding per line: the first rule
7
+ // that matches wins, so a provider key isn't also reported as a generic secret.
8
+ const RULES = [
9
+ { name: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, severity: "high" },
10
+ { name: "OpenAI API key", re: /\bsk-(proj-)?[A-Za-z0-9_-]{20,}\b/, severity: "high" },
11
+ { name: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/, severity: "critical" },
12
+ { name: "GitHub token", re: /\bghp_[A-Za-z0-9]{36,}\b/, severity: "high" },
13
+ { name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
14
+ { name: "Stripe secret key", re: /\bsk_live_[0-9a-zA-Z]{24,}\b/, severity: "critical" },
15
+ { name: "Slack token", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/, severity: "high" },
16
+ { name: "Private key block", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, severity: "critical" },
17
+ { name: "Password in connection string", re: /[a-z]+:\/\/[^:\s/]+:[^@\s/]{4,}@/i, severity: "high" },
18
+ // Case-sensitive: real env keys are UPPER_SNAKE; this won't match `liveSecret = x`.
19
+ // Gated to env files so `const X = process.env.Y` in code isn't flagged.
20
+ { name: "Secret in env assignment", re: /\b[A-Z][A-Z0-9_]*(?:SECRET|PASSWORD|PASSWD|API_?KEY|TOKEN|ACCESS_?KEY|PRIVATE_?KEY)[A-Z0-9_]*\s*=\s*[^\s"'#]{8,}/, severity: "high", envOnly: true },
21
+ { name: "Generic assigned secret", re: /(?:secret|password|passwd|api[_-]?key|token)\s*[:=]\s*["'][A-Za-z0-9_\-./+]{12,}["']/i, severity: "medium" },
22
+ ];
23
+ /** True for files where a committed secret is especially dangerous. */
24
+ function isEnvLike(relPath) {
25
+ return /(^|\/|\\)\.env(\.|$)|\.env\.|config\.(json|ya?ml)$/i.test(relPath);
26
+ }
27
+ export function detectSecrets(relPath, code) {
28
+ const out = [];
29
+ const envLike = isEnvLike(relPath);
30
+ const activeRules = RULES.filter((r) => !r.envOnly || envLike);
31
+ const lines = code.split(/\r?\n/);
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i];
34
+ // First matching rule wins for this line (rules ordered specific→generic).
35
+ const rule = activeRules.find((r) => r.re.test(line));
36
+ if (!rule)
37
+ continue;
38
+ const m = rule.re.exec(line);
39
+ if (!m)
40
+ continue;
41
+ {
42
+ const envBoost = isEnvLike(relPath);
43
+ const dedupeKey = `${relPath}:${i}:${rule.name}`;
44
+ out.push({
45
+ id: createHash("sha1").update(dedupeKey).digest("hex").slice(0, 12),
46
+ detector: "hardcoded-secret",
47
+ title: `Hardcoded secret: ${rule.name}`,
48
+ category: "classic",
49
+ severity: envBoost && rule.severity !== "critical" ? "high" : rule.severity,
50
+ confidence: rule.name === "Generic assigned secret" ? 0.55 : 0.9,
51
+ reachable: true,
52
+ cwe: "CWE-798",
53
+ owasp: "A07:2021 Identification and Authentication Failures",
54
+ mitre: "T1552.001",
55
+ file: relPath,
56
+ line: i + 1,
57
+ column: Math.max(1, line.indexOf(m[0]) + 1),
58
+ evidence: redact(line.trim(), m[0]),
59
+ why: "A live credential is committed to the repository. Anyone with repo access (or anyone the repo is leaked to) can use it directly.",
60
+ fix: "Remove the secret, rotate it immediately, load it from a secrets manager / environment at runtime, and add the file to .gitignore. Treat the committed value as compromised.",
61
+ });
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function redact(line, secret) {
67
+ if (secret.length <= 8)
68
+ return line.replace(secret, "****");
69
+ const masked = secret.slice(0, 4) + "…" + secret.slice(-2);
70
+ return line.replace(secret, masked);
71
+ }
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ import { loc, lineOf } from "../ast.js";
3
+ /** Build a finding with a stable id (the `node_key` analogue from doc 03/10). */
4
+ export function mkFinding(ctx, draft) {
5
+ const position = draft.node ? loc(draft.node) : { line: draft.line ?? 1, column: draft.column ?? 1 };
6
+ const evidence = draft.evidence ?? lineOf(ctx.file.code, position.line);
7
+ const stableKey = `${ctx.file.relPath}:${draft.detector}:${position.line}:${draft.title}`;
8
+ const id = createHash("sha1").update(stableKey).digest("hex").slice(0, 12);
9
+ return {
10
+ id,
11
+ detector: draft.detector,
12
+ title: draft.title,
13
+ category: draft.category,
14
+ severity: draft.severity,
15
+ confidence: draft.confidence,
16
+ reachable: draft.reachable,
17
+ cwe: draft.cwe,
18
+ owasp: draft.owasp,
19
+ mitre: draft.mitre,
20
+ file: ctx.file.relPath,
21
+ line: position.line,
22
+ column: position.column,
23
+ evidence,
24
+ why: draft.why,
25
+ fix: draft.fix,
26
+ taint: draft.taint,
27
+ };
28
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Deterministic fixers (doc 05) — the offline floor. These are safe, rule-based
3
+ * code transforms for vulnerability classes whose fix is unambiguous. They run
4
+ * with zero setup (no API key, no network) and are fully reproducible.
5
+ *
6
+ * Each fixer rewrites *all* occurrences of its pattern in a file in one pass, so
7
+ * we never depend on stale line numbers (no offset drift between edits).
8
+ * Anything ambiguous is deliberately left to the LLM fixer or to a manual note —
9
+ * a transform that might break the app is worse than no transform.
10
+ */
11
+ import path from "node:path";
12
+ import { parse, langForExtension } from "../parser.js";
13
+ import { walk, isCall, calleeLastSegment } from "../ast.js";
14
+ import { lookup } from "../deps/db.js";
15
+ import { parseManifest } from "../deps/manifests.js";
16
+ import { isVulnerable, minFixedVersion } from "../deps/match.js";
17
+ /** Detectors a deterministic fixer can address. */
18
+ export const DETERMINISTIC_DETECTORS = new Set([
19
+ "cors-misconfig",
20
+ "weak-jwt",
21
+ "hardcoded-secret",
22
+ "sql-injection",
23
+ "vulnerable-dependency",
24
+ ]);
25
+ export async function deterministicFix(input) {
26
+ const result = {
27
+ code: input.code,
28
+ attempted: new Set(),
29
+ manual: new Map(),
30
+ sideEffects: [],
31
+ };
32
+ fixCors(input, result);
33
+ fixJwt(input, result);
34
+ fixSecrets(input, result);
35
+ await fixSql(input, result);
36
+ fixDeps(input, result);
37
+ return result;
38
+ }
39
+ /** Mark every finding of `detector` in the file as attempted. */
40
+ function markAttempted(input, result, detector) {
41
+ for (const f of input.findings) {
42
+ if (f.detector === detector)
43
+ result.attempted.add(f.id);
44
+ }
45
+ }
46
+ // ── CORS ──────────────────────────────────────────────────────────────────
47
+ // Wildcard Access-Control-Allow-Origin → an env-driven allowlist. Never pair a
48
+ // wildcard with credentials.
49
+ function fixCors(input, result) {
50
+ if (!input.findings.some((f) => f.detector === "cors-misconfig"))
51
+ return;
52
+ let code = result.code;
53
+ const before = code;
54
+ // Next.js header form: { key: "Access-Control-Allow-Origin", value: "*" }
55
+ code = code.replace(/(["']Access-Control-Allow-Origin["'][\s\S]{0,40}?value\s*:\s*)["']\*["']/gi, '$1process.env.ALLOWED_ORIGIN ?? ""');
56
+ // setHeader / header form: "Access-Control-Allow-Origin", "*"
57
+ code = code.replace(/(["']Access-Control-Allow-Origin["']\s*,\s*)["']\*["']/gi, '$1process.env.ALLOWED_ORIGIN ?? ""');
58
+ // cors() lib form: origin: "*"
59
+ code = code.replace(/(\borigin\s*:\s*)["']\*["']/gi, "$1process.env.ALLOWED_ORIGIN");
60
+ if (code !== before) {
61
+ result.code = code;
62
+ markAttempted(input, result, "cors-misconfig");
63
+ }
64
+ }
65
+ // ── Weak JWT ─────────────────────────────────────────────────────────────
66
+ // The 'none' algorithm → RS256.
67
+ function fixJwt(input, result) {
68
+ if (!input.findings.some((f) => f.detector === "weak-jwt"))
69
+ return;
70
+ const before = result.code;
71
+ const code = before.replace(/(alg(?:orithm)?s?\s*[:=]\s*\[?\s*["'])none(["'])/gi, "$1RS256$2");
72
+ if (code !== before) {
73
+ result.code = code;
74
+ markAttempted(input, result, "weak-jwt");
75
+ }
76
+ }
77
+ // ── Secrets ──────────────────────────────────────────────────────────────
78
+ // A committed secret cannot be "fixed" in place — the value is already leaked
79
+ // and must be rotated. What we *can* do safely: ensure .env is gitignored and
80
+ // surface a rotate-now instruction. Source-file literals are replaced with an
81
+ // env reference so the literal stops being committed.
82
+ function fixSecrets(input, result) {
83
+ const secretFindings = input.findings.filter((f) => f.detector === "hardcoded-secret");
84
+ if (secretFindings.length === 0)
85
+ return;
86
+ const isEnvFile = /(^|\/)\.env(\.|$)/i.test(input.relPath);
87
+ if (isEnvFile) {
88
+ // Helpful side effect, but the finding stays (the value is still in the file
89
+ // and was likely already committed) → honest outcome is "manual: rotate".
90
+ result.sideEffects.push({
91
+ kind: "ensure-line",
92
+ relPath: ".gitignore",
93
+ content: ".env",
94
+ reason: "prevent committing .env going forward",
95
+ });
96
+ for (const f of secretFindings) {
97
+ result.manual.set(f.id, "Added .env to .gitignore. The committed value is already exposed — rotate this credential now and load it from a secrets manager.");
98
+ }
99
+ return;
100
+ }
101
+ // Source file: replace the literal assignment with an env reference.
102
+ const lang = langForExtension(path.extname(input.relPath));
103
+ let code = result.code;
104
+ let changed = false;
105
+ const exampleKeys = [];
106
+ for (const f of secretFindings) {
107
+ const lineIdx = f.line - 1;
108
+ const lines = code.split(/\r?\n/);
109
+ const line = lines[lineIdx];
110
+ if (!line)
111
+ continue;
112
+ // Match `<lhs> = "<secret>"` / `<lhs>: "<secret>"` and swap the literal.
113
+ const m = line.match(/^(\s*(?:const|let|var|public|private)?\s*([A-Za-z_$][\w$]*)\s*[:=]\s*)(["'])(.+?)\3/);
114
+ if (!m)
115
+ continue;
116
+ const envName = toEnvName(m[2]);
117
+ const ref = lang === "python" ? `os.environ["${envName}"]` : `process.env.${envName}`;
118
+ lines[lineIdx] = line.replace(/(["']).+?\1/, ref);
119
+ code = lines.join("\n");
120
+ exampleKeys.push(envName);
121
+ changed = true;
122
+ result.attempted.add(f.id);
123
+ }
124
+ if (changed) {
125
+ result.code = code;
126
+ result.sideEffects.push({
127
+ kind: "ensure-line",
128
+ relPath: ".gitignore",
129
+ content: ".env",
130
+ reason: "keep real secrets out of version control",
131
+ });
132
+ if (exampleKeys.length > 0) {
133
+ result.sideEffects.push({
134
+ kind: "write-if-absent",
135
+ relPath: ".env.example",
136
+ content: exampleKeys.map((k) => `${k}=`).join("\n") + "\n",
137
+ reason: "document the required environment variables",
138
+ });
139
+ }
140
+ }
141
+ }
142
+ function toEnvName(identifier) {
143
+ return identifier
144
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
145
+ .replace(/[^A-Za-z0-9]+/g, "_")
146
+ .toUpperCase();
147
+ }
148
+ function sqlPlaceholder(lang, inv) {
149
+ const deps = inv.dependencies.map((d) => d.toLowerCase());
150
+ if (lang === "python") {
151
+ if (deps.some((d) => /psycopg|mysqlclient|pymysql|mariadb/.test(d)))
152
+ return "percent";
153
+ return "qmark"; // sqlite3 (stdlib) and most DB-API qmark drivers
154
+ }
155
+ if (deps.includes("pg") || deps.includes("postgres") || deps.includes("postgresql"))
156
+ return "numbered";
157
+ if (deps.some((d) => /mysql|sqlite|better-sqlite3/.test(d)))
158
+ return "qmark";
159
+ return null; // unknown driver → don't guess, leave to the LLM
160
+ }
161
+ const SQL_SINKS = new Set(["query", "execute", "exec", "raw", "$queryRawUnsafe", "$executeRawUnsafe"]);
162
+ const SIMPLE_EXPR = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[(?:'[^']*'|"[^"]*"|\d+)\])*$/;
163
+ async function fixSql(input, result) {
164
+ if (!input.findings.some((f) => f.detector === "sql-injection"))
165
+ return;
166
+ const lang = langForExtension(path.extname(input.relPath));
167
+ if (!lang)
168
+ return;
169
+ const style = sqlPlaceholder(lang, input.inventory);
170
+ if (!style)
171
+ return;
172
+ let root;
173
+ try {
174
+ ({ root } = await parse(result.code, lang));
175
+ }
176
+ catch {
177
+ return;
178
+ }
179
+ const edits = [];
180
+ walk(root, (n) => {
181
+ if (!isCall(n))
182
+ return;
183
+ const last = calleeLastSegment(n);
184
+ if (!last || !SQL_SINKS.has(last))
185
+ return;
186
+ const argsNode = n.childForFieldName("arguments");
187
+ if (!argsNode || argsNode.namedChildCount !== 1)
188
+ return; // already has params, or none
189
+ const arg = argsNode.namedChild(0);
190
+ if (!arg)
191
+ return;
192
+ const converted = lang === "python" ? convertPyFString(arg, style) : convertJsTemplate(arg, style);
193
+ if (converted)
194
+ edits.push({ start: arg.startIndex, end: arg.endIndex, text: converted });
195
+ });
196
+ if (edits.length === 0)
197
+ return;
198
+ // Apply from last to first so earlier offsets stay valid.
199
+ edits.sort((a, b) => b.start - a.start);
200
+ let code = result.code;
201
+ for (const e of edits)
202
+ code = code.slice(0, e.start) + e.text + code.slice(e.end);
203
+ result.code = code;
204
+ markAttempted(input, result, "sql-injection");
205
+ }
206
+ /** `` `… ${a} …` `` → `'… <ph> …', [a]` (returns null if not safely convertible). */
207
+ function convertJsTemplate(arg, style) {
208
+ if (arg.type !== "template_string")
209
+ return null;
210
+ const raw = arg.text;
211
+ if (!raw.startsWith("`") || !raw.endsWith("`"))
212
+ return null;
213
+ const inner = raw.slice(1, -1);
214
+ if (/[\r\n]/.test(inner))
215
+ return null; // keep it to single-line, simple cases
216
+ const params = [];
217
+ let bad = false;
218
+ let n = 0;
219
+ const sql = inner.replace(/\$\{\s*([^}]+?)\s*\}/g, (_m, expr) => {
220
+ if (!SIMPLE_EXPR.test(expr.trim()))
221
+ bad = true;
222
+ params.push(expr.trim());
223
+ n += 1;
224
+ return placeholder(style, n);
225
+ });
226
+ if (bad || params.length === 0)
227
+ return null;
228
+ return `'${escapeSingle(sql)}', [${params.join(", ")}]`;
229
+ }
230
+ /** `f"… {a} …"` → `"… <ph> …", (a,)` (returns null if not safely convertible). */
231
+ function convertPyFString(arg, style) {
232
+ if (arg.type !== "string")
233
+ return null;
234
+ const raw = arg.text;
235
+ const m = raw.match(/^([a-zA-Z]*)(['"]{1,3})([\s\S]*)\2$/);
236
+ if (!m)
237
+ return null;
238
+ const prefix = m[1].toLowerCase();
239
+ if (!prefix.includes("f"))
240
+ return null; // only f-strings carry interpolation
241
+ if (prefix.includes("r"))
242
+ return null; // raw f-strings: leave alone
243
+ const quote = m[2];
244
+ if (quote.length !== 1)
245
+ return null; // skip triple-quoted multi-line
246
+ const inner = m[3];
247
+ if (/\{\{|\}\}/.test(inner))
248
+ return null; // escaped braces → too subtle, bail
249
+ const params = [];
250
+ let bad = false;
251
+ let n = 0;
252
+ const sql = inner.replace(/\{\s*([^}{]+?)\s*\}/g, (_mm, expr) => {
253
+ const e = expr.split(/[:!]/)[0].trim(); // drop format spec / conversion
254
+ if (!SIMPLE_EXPR.test(e))
255
+ bad = true;
256
+ params.push(e);
257
+ n += 1;
258
+ return placeholder(style, n);
259
+ });
260
+ if (bad || params.length === 0)
261
+ return null;
262
+ const tuple = params.length === 1 ? `(${params[0]},)` : `(${params.join(", ")})`;
263
+ return `${quote}${sql}${quote}, ${tuple}`;
264
+ }
265
+ function placeholder(style, n) {
266
+ if (style === "numbered")
267
+ return `$${n}`;
268
+ if (style === "percent")
269
+ return "%s";
270
+ return "?";
271
+ }
272
+ function escapeSingle(s) {
273
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
274
+ }
275
+ // ── Vulnerable dependencies ─────────────────────────────────────────────────
276
+ // Bump the manifest spec to the minimum fixed version, preserving the range
277
+ // operator (^14.x stays caret). Re-derives the target from the db so there is a
278
+ // single source of truth. The verify gate re-matches the new version.
279
+ function fixDeps(input, result) {
280
+ if (!input.depDb)
281
+ return;
282
+ if (!input.findings.some((f) => f.detector === "vulnerable-dependency"))
283
+ return;
284
+ let code = result.code;
285
+ let changed = false;
286
+ for (const dep of parseManifest(input.relPath, code)) {
287
+ const advisories = lookup(input.depDb, dep.ecosystem, dep.name);
288
+ const vulnerable = advisories.filter((a) => isVulnerable(dep.baseVersion, a.ranges));
289
+ if (vulnerable.length === 0)
290
+ continue;
291
+ // Highest minimum-fix across all advisories so one bump clears them all.
292
+ let target = null;
293
+ for (const a of vulnerable) {
294
+ const f = minFixedVersion(dep.baseVersion, a.ranges);
295
+ if (f && (target === null || cmpVer(f, target) > 0))
296
+ target = f;
297
+ }
298
+ if (!target)
299
+ continue; // no patched release → leave for the manual note
300
+ const newSpec = `${dep.rangePrefix}${target}`;
301
+ const replaced = replaceSpec(code, dep.ecosystem, dep.name, dep.version, newSpec);
302
+ if (replaced !== code) {
303
+ code = replaced;
304
+ changed = true;
305
+ }
306
+ }
307
+ if (changed) {
308
+ result.code = code;
309
+ markAttempted(input, result, "vulnerable-dependency");
310
+ }
311
+ }
312
+ function cmpVer(a, b) {
313
+ // local re-export of the deps comparator to avoid a wider import surface
314
+ const pa = a.split(/[-+]/)[0].split(".").map((n) => parseInt(n, 10) || 0);
315
+ const pb = b.split(/[-+]/)[0].split(".").map((n) => parseInt(n, 10) || 0);
316
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
317
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
318
+ if (x !== y)
319
+ return x < y ? -1 : 1;
320
+ }
321
+ return 0;
322
+ }
323
+ function replaceSpec(code, ecosystem, name, oldSpec, newSpec) {
324
+ if (ecosystem === "npm") {
325
+ // "name": "<oldSpec>" → "name": "<newSpec>"
326
+ const re = new RegExp(`("${escapeReDep(name)}"\\s*:\\s*")${escapeReDep(oldSpec)}(")`);
327
+ return code.replace(re, `$1${newSpec}$2`);
328
+ }
329
+ // requirements.txt: name<op><version> on its own line
330
+ const re = new RegExp(`(^|\\n)(\\s*${escapeReDep(name)}\\s*)${escapeReDep(oldSpec)}`);
331
+ return code.replace(re, `$1$2${newSpec}`);
332
+ }
333
+ function escapeReDep(s) {
334
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
335
+ }
@@ -0,0 +1,118 @@
1
+ /** Detectors routed to the LLM fixer. */
2
+ export const LLM_DETECTORS = new Set([
3
+ "code-execution",
4
+ "ai-tool-hijack",
5
+ "ai-missing-firewall",
6
+ "ai-prompt-injection",
7
+ "ai-overprivileged-tool",
8
+ "ai-rag-injection",
9
+ "ai-mcp-unauth",
10
+ "xss",
11
+ "broken-access-control",
12
+ "sql-injection", // the shapes the deterministic fixer declined
13
+ ]);
14
+ /** Files larger than this are left to manual review (a single rewrite gets unreliable). */
15
+ const MAX_FILE_CHARS = 16_000;
16
+ export function defaultModel() {
17
+ return process.env.SECUREVIBE_MODEL || "claude-sonnet-4-6";
18
+ }
19
+ export function llmAvailability(disabled) {
20
+ const model = defaultModel();
21
+ if (disabled)
22
+ return { available: false, reason: "disabled (--no-llm)", model };
23
+ if (!process.env.ANTHROPIC_API_KEY)
24
+ return { available: false, reason: "no ANTHROPIC_API_KEY", model };
25
+ return { available: true, model };
26
+ }
27
+ const SYSTEM = [
28
+ "You are a senior application-security engineer fixing vulnerabilities in source code.",
29
+ "You will receive one file and a list of confirmed security findings in it.",
30
+ "Rewrite the file to remediate EVERY listed finding, using the smallest change that is correct:",
31
+ "- Parameterize SQL; never build queries by string interpolation.",
32
+ "- For AI/agent tool execution: enforce a strict tool allowlist AND validate tool-call arguments",
33
+ " against an explicit schema before dispatch; constrain dangerous tools (filesystem/shell) to a",
34
+ " safe, fixed scope. Inline the guard logic in this file — do not import a new module.",
35
+ "- Replace eval/exec/os.system on untrusted input with safe APIs or a fixed allowlist of commands.",
36
+ "- Add ownership/authorization checks before returning a resource fetched by a client-supplied id.",
37
+ "Preserve the file's language, imports, style, and all unrelated behavior. Do not add commentary.",
38
+ "Return ONLY the complete corrected file content, wrapped exactly as:",
39
+ "<file>",
40
+ "...full file...",
41
+ "</file>",
42
+ ].join("\n");
43
+ /** Returns the corrected file content, or null if unavailable / declined / unreliable. */
44
+ export async function llmFix(input) {
45
+ if (!process.env.ANTHROPIC_API_KEY)
46
+ return null;
47
+ if (input.code.length > MAX_FILE_CHARS)
48
+ return null;
49
+ let Anthropic;
50
+ try {
51
+ ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
52
+ }
53
+ catch {
54
+ return null; // SDK not installed → graceful fallback
55
+ }
56
+ const findingList = input.findings
57
+ .map((f, i) => `${i + 1}. [${f.severity}] ${f.detector} at line ${f.line}: ${f.title}\n Why: ${f.why}\n Intended fix: ${f.fix}`)
58
+ .join("\n");
59
+ const user = [
60
+ `File: ${input.relPath} (${input.lang})`,
61
+ "",
62
+ "Findings to fix:",
63
+ findingList,
64
+ "",
65
+ "Current file content:",
66
+ "<file>",
67
+ input.code,
68
+ "</file>",
69
+ ].join("\n");
70
+ try {
71
+ const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
72
+ const msg = await client.messages.create({
73
+ model: defaultModel(),
74
+ max_tokens: 4096,
75
+ system: SYSTEM,
76
+ messages: [{ role: "user", content: user }],
77
+ });
78
+ const text = extractText(msg);
79
+ return parseFile(text, input.code);
80
+ }
81
+ catch {
82
+ return null; // network / auth / quota failure → fall back to manual
83
+ }
84
+ }
85
+ /** Exported for unit testing the response plumbing without a live API call. */
86
+ export function extractText(msg) {
87
+ const blocks = Array.isArray(msg?.content) ? msg.content : [];
88
+ return blocks
89
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
90
+ .map((b) => b.text)
91
+ .join("");
92
+ }
93
+ /**
94
+ * Pull the rewritten file out of the response and sanity-check it. We reject a
95
+ * result that is suspiciously short relative to the original (likely truncated),
96
+ * since the verification gate can't catch "valid but incomplete".
97
+ */
98
+ export function parseFile(text, original) {
99
+ if (!text)
100
+ return null;
101
+ let body = null;
102
+ const tagged = text.match(/<file>\n?([\s\S]*?)\n?<\/file>/);
103
+ if (tagged)
104
+ body = tagged[1];
105
+ if (body == null) {
106
+ const fenced = text.match(/```[a-zA-Z0-9]*\n([\s\S]*?)```/);
107
+ if (fenced)
108
+ body = fenced[1];
109
+ }
110
+ if (body == null)
111
+ body = text;
112
+ body = body.replace(/\s+$/, "") + "\n";
113
+ if (body.trim().length === 0)
114
+ return null;
115
+ if (body.length < original.length * 0.5)
116
+ return null; // likely truncated
117
+ return body;
118
+ }