securevibe 0.1.3 → 0.1.7
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 +15 -7
- package/dist/config.js +78 -0
- package/dist/engine/detectors/classic.js +248 -0
- package/dist/engine/fix/llm.js +97 -31
- package/dist/engine/fix/session.js +2 -1
- package/dist/engine/readiness/scorecard.js +1 -1
- package/dist/index.js +90 -5
- package/dist/ui/fix.js +11 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,10 +70,17 @@ Anything that fails the gate is rolled back and surfaced as a manual instruction
|
|
|
70
70
|
- **Deterministic, offline (zero setup):** parameterize SQL (driver-aware placeholders),
|
|
71
71
|
lock wildcard CORS to an env-driven allowlist, pin weak JWT algorithms, and move
|
|
72
72
|
hardcoded secrets to env references / gitignore `.env`.
|
|
73
|
-
- **
|
|
74
|
-
rule can't safely rewrite — RCE/command-injection, AI
|
|
75
|
-
DOM XSS, and complex SQL. One rewrite per file; the result
|
|
76
|
-
verification gate**, otherwise it falls back to a precise manual
|
|
73
|
+
- **AI-powered (optional, set `GROQ_API_KEY` for the free tier or `ANTHROPIC_API_KEY` for Claude):**
|
|
74
|
+
*targets* the logic-level classes a rule can't safely rewrite — RCE/command-injection, AI
|
|
75
|
+
tool-hijack / missing firewall, IDOR, DOM XSS, and complex SQL. One rewrite per file; the result
|
|
76
|
+
is kept **only if it passes the verification gate**, otherwise it falls back to a precise manual
|
|
77
|
+
instruction. Groq is used by default when both keys are present (lower bar to try it at all);
|
|
78
|
+
set `SECUREVIBE_LLM_PROVIDER=anthropic` to force Claude instead.
|
|
79
|
+
- **Setting a key:** either export it yourself (`GROQ_API_KEY=...`), or run
|
|
80
|
+
`securevibe config set-key groq` — it prompts interactively (never as a command argument, so it
|
|
81
|
+
never lands in shell history) and saves it to `~/.securevibe/config.json`, used automatically by
|
|
82
|
+
`fix` whenever the env var isn't already set. Run `fix --apply` with no key configured and it will
|
|
83
|
+
offer to set one up on the spot. `securevibe config show` lists what's stored (masked).
|
|
77
84
|
|
|
78
85
|
**Honesty (read this):**
|
|
79
86
|
- A "fixed" finding means *our detector no longer flags it* — not that the app is proven secure.
|
|
@@ -82,9 +89,10 @@ Anything that fails the gate is rolled back and surfaced as a manual instruction
|
|
|
82
89
|
*recognizes* (a tool allowlist + argument-schema validation — what the LLM prompt asks for).
|
|
83
90
|
The prompt-injection surface typically remains (input still reaches the model) and is reported
|
|
84
91
|
as a residual to address with input isolation.
|
|
85
|
-
- The deterministic, offline fixers are verified end-to-end here. The **
|
|
86
|
-
and unit-tested (response parsing,
|
|
87
|
-
key in this build** — set
|
|
92
|
+
- The deterministic, offline fixers are verified end-to-end here. The **AI-powered path (Groq or
|
|
93
|
+
Claude) is wired and unit-tested (provider selection, response parsing, the truncation and
|
|
94
|
+
verification gates) but has not been exercised against a real API key in this build** — set
|
|
95
|
+
`GROQ_API_KEY` or `ANTHROPIC_API_KEY` and review the diffs it proposes.
|
|
88
96
|
- `fix` is **dry-run by default**; `--apply` backs up originals to `.securevibe-backup/<ts>/` first.
|
|
89
97
|
- `fix --apply` is **interactive**: it shows each verified diff and asks `[y]es / [n]o / [a]ll / [q]uit`
|
|
90
98
|
before writing. A declined change is reported `skipped`, never `fixed`, and the after-score reflects
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local provider-key storage for the optional AI-powered fixer (doc 05). An
|
|
3
|
+
* explicit shell env var (GROQ_API_KEY / ANTHROPIC_API_KEY) always wins over
|
|
4
|
+
* whatever is stored here — this file only supplies a default when neither is
|
|
5
|
+
* already set, so `fix` doesn't require re-exporting a key every session.
|
|
6
|
+
*
|
|
7
|
+
* Storage note (be precise, not reassuring): this is a plain JSON file under
|
|
8
|
+
* the user's home directory. On macOS/Linux we set file mode 0600 (owner
|
|
9
|
+
* read/write only). On Windows, Node cannot restrict file ACLs by owner/group
|
|
10
|
+
* (the group/owner/other distinction isn't implemented there — only the
|
|
11
|
+
* read-only bit is), so the real protection on Windows is that the file lives
|
|
12
|
+
* under the user's own profile directory, which the OS already scopes to that
|
|
13
|
+
* account — not the file mode.
|
|
14
|
+
*/
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
const KEY_FIELD = {
|
|
19
|
+
groq: "groqApiKey",
|
|
20
|
+
anthropic: "anthropicApiKey",
|
|
21
|
+
};
|
|
22
|
+
const ENV_VAR = {
|
|
23
|
+
groq: "GROQ_API_KEY",
|
|
24
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
25
|
+
};
|
|
26
|
+
export function configDir() {
|
|
27
|
+
return path.join(os.homedir(), ".securevibe");
|
|
28
|
+
}
|
|
29
|
+
export function configFilePath() {
|
|
30
|
+
return path.join(configDir(), "config.json");
|
|
31
|
+
}
|
|
32
|
+
/** Tolerant read: a missing or corrupt file is treated as an empty config. */
|
|
33
|
+
export async function readConfig(file = configFilePath()) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await fs.readFile(file, "utf8");
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return typeof parsed === "object" && parsed ? parsed : {};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function writeConfig(patch, file = configFilePath()) {
|
|
44
|
+
const dir = path.dirname(file);
|
|
45
|
+
await fs.mkdir(dir, { recursive: true });
|
|
46
|
+
const current = await readConfig(file);
|
|
47
|
+
const next = { ...current, ...patch };
|
|
48
|
+
await fs.writeFile(file, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
export async function setKey(provider, key, file) {
|
|
51
|
+
await writeConfig({ [KEY_FIELD[provider]]: key }, file);
|
|
52
|
+
}
|
|
53
|
+
export async function unsetKey(provider, file = configFilePath()) {
|
|
54
|
+
const current = await readConfig(file);
|
|
55
|
+
delete current[KEY_FIELD[provider]];
|
|
56
|
+
const dir = path.dirname(file);
|
|
57
|
+
await fs.mkdir(dir, { recursive: true });
|
|
58
|
+
await fs.writeFile(file, JSON.stringify(current, null, 2) + "\n", { mode: 0o600 });
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Copy any stored key into process.env, but only for a provider whose env var
|
|
62
|
+
* isn't already set — an explicit shell export always takes precedence over
|
|
63
|
+
* what's on disk. Call once at the start of a command that may need a key.
|
|
64
|
+
*/
|
|
65
|
+
export async function applyStoredKeysToEnv(file = configFilePath()) {
|
|
66
|
+
const cfg = await readConfig(file);
|
|
67
|
+
if (cfg.groqApiKey && !process.env.GROQ_API_KEY)
|
|
68
|
+
process.env.GROQ_API_KEY = cfg.groqApiKey;
|
|
69
|
+
if (cfg.anthropicApiKey && !process.env.ANTHROPIC_API_KEY)
|
|
70
|
+
process.env.ANTHROPIC_API_KEY = cfg.anthropicApiKey;
|
|
71
|
+
}
|
|
72
|
+
/** Masked display form: first 6 chars + last 4, for showing "is a key configured" without leaking it. */
|
|
73
|
+
export function maskKey(key) {
|
|
74
|
+
if (key.length <= 10)
|
|
75
|
+
return "*".repeat(key.length);
|
|
76
|
+
return `${key.slice(0, 6)}${"*".repeat(Math.max(4, key.length - 10))}${key.slice(-4)}`;
|
|
77
|
+
}
|
|
78
|
+
export { ENV_VAR };
|
|
@@ -350,6 +350,249 @@ const insecureConfig = (ctx) => {
|
|
|
350
350
|
}
|
|
351
351
|
return out;
|
|
352
352
|
};
|
|
353
|
+
/**
|
|
354
|
+
* Insecure deserialization. `yaml.load` without an explicit safe loader
|
|
355
|
+
* deserializes arbitrary Python objects from the document — flagged
|
|
356
|
+
* unconditionally (there is no safe use of the bare form). `pickle.loads` /
|
|
357
|
+
* `marshal.loads` are only dangerous when fed untrusted bytes, so those are
|
|
358
|
+
* taint-gated.
|
|
359
|
+
*/
|
|
360
|
+
const YAML_LOAD_RE = /\byaml\.load\s*\(/;
|
|
361
|
+
const YAML_SAFE_LOADER_RE = /Loader\s*=\s*(yaml\.)?(SafeLoader|CSafeLoader|FullLoader)\b|yaml\.safe_load\b/;
|
|
362
|
+
const insecureDeserialization = (ctx) => {
|
|
363
|
+
const out = [];
|
|
364
|
+
if (ctx.file.lang === "python") {
|
|
365
|
+
const code = codeWithoutComments(ctx.root, ctx.file.code);
|
|
366
|
+
if (YAML_LOAD_RE.test(code) && !YAML_SAFE_LOADER_RE.test(code)) {
|
|
367
|
+
out.push(mkFinding(ctx, {
|
|
368
|
+
line: lineNumberOf(code, YAML_LOAD_RE),
|
|
369
|
+
detector: "insecure-deserialization",
|
|
370
|
+
title: "Insecure deserialization: yaml.load without a safe loader",
|
|
371
|
+
category: "classic",
|
|
372
|
+
severity: "critical",
|
|
373
|
+
confidence: 0.85,
|
|
374
|
+
reachable: true,
|
|
375
|
+
cwe: "CWE-502",
|
|
376
|
+
owasp: "A08:2021 Software and Data Integrity Failures",
|
|
377
|
+
why: "yaml.load() with the default (or Loader=yaml.Loader) can construct arbitrary Python objects from the document, letting an attacker who controls the YAML achieve code execution.",
|
|
378
|
+
fix: "Use yaml.safe_load() or pass Loader=yaml.SafeLoader explicitly.",
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
walk(ctx.root, (n) => {
|
|
383
|
+
if (!isCall(n))
|
|
384
|
+
return;
|
|
385
|
+
const callee = calleeText(n) ?? "";
|
|
386
|
+
const isPickle = /^pickle\.(loads?|Unpickler)\b/.test(callee);
|
|
387
|
+
const isMarshal = /^marshal\.loads?\b/.test(callee);
|
|
388
|
+
const isNodeSerialize = /^(serialize|node-serialize)\.unserialize\b/.test(callee) || callee === "unserialize";
|
|
389
|
+
if (!isPickle && !isMarshal && !isNodeSerialize)
|
|
390
|
+
return;
|
|
391
|
+
const args = argsText(n);
|
|
392
|
+
const tainted = isTainted(args, ctx.taint) || isUntrustedStringBuild(args, ctx.taint);
|
|
393
|
+
if (!tainted)
|
|
394
|
+
return;
|
|
395
|
+
out.push(mkFinding(ctx, {
|
|
396
|
+
node: n,
|
|
397
|
+
detector: "insecure-deserialization",
|
|
398
|
+
title: `Insecure deserialization: untrusted data reaches ${callee}()`,
|
|
399
|
+
category: "classic",
|
|
400
|
+
severity: "critical",
|
|
401
|
+
confidence: 0.85,
|
|
402
|
+
reachable: true,
|
|
403
|
+
cwe: "CWE-502",
|
|
404
|
+
owasp: "A08:2021 Software and Data Integrity Failures",
|
|
405
|
+
why: "Deserializing untrusted bytes with pickle/marshal/node-serialize can execute arbitrary code embedded in the payload during deserialization.",
|
|
406
|
+
fix: "Never deserialize untrusted data with pickle/marshal/node-serialize. Use a data-only format (JSON) or sign/verify the payload before deserializing.",
|
|
407
|
+
taint: ["request input", `→ ${callee}()`],
|
|
408
|
+
}));
|
|
409
|
+
});
|
|
410
|
+
return out;
|
|
411
|
+
};
|
|
412
|
+
/** Open redirect: untrusted input controls a server-issued redirect target. */
|
|
413
|
+
const REDIRECT_CALLEE_RE = /(^|\.)redirect$/;
|
|
414
|
+
const openRedirect = (ctx) => {
|
|
415
|
+
const out = [];
|
|
416
|
+
walk(ctx.root, (n) => {
|
|
417
|
+
if (!isCall(n))
|
|
418
|
+
return;
|
|
419
|
+
const callee = calleeText(n) ?? "";
|
|
420
|
+
if (!REDIRECT_CALLEE_RE.test(callee))
|
|
421
|
+
return;
|
|
422
|
+
const argsNode = n.childForFieldName("arguments");
|
|
423
|
+
const firstArg = argsNode?.namedChild(0)?.text ?? "";
|
|
424
|
+
if (!firstArg)
|
|
425
|
+
return;
|
|
426
|
+
if (!isTainted(firstArg, ctx.taint) && !isUntrustedStringBuild(firstArg, ctx.taint))
|
|
427
|
+
return;
|
|
428
|
+
out.push(mkFinding(ctx, {
|
|
429
|
+
node: n,
|
|
430
|
+
detector: "open-redirect",
|
|
431
|
+
title: "Open redirect: untrusted input controls the redirect target",
|
|
432
|
+
category: "classic",
|
|
433
|
+
severity: "medium",
|
|
434
|
+
confidence: 0.7,
|
|
435
|
+
reachable: true,
|
|
436
|
+
cwe: "CWE-601",
|
|
437
|
+
owasp: "A01:2021 Broken Access Control",
|
|
438
|
+
why: "A request-controlled value is used as a redirect target. An attacker can craft a link on your domain that redirects victims to a phishing site.",
|
|
439
|
+
fix: "Validate the redirect target against an allowlist of known paths/hosts, or only allow relative paths within your own app.",
|
|
440
|
+
taint: ["request input", "→ redirect target", `→ ${callee}()`],
|
|
441
|
+
}));
|
|
442
|
+
});
|
|
443
|
+
return out;
|
|
444
|
+
};
|
|
445
|
+
/** Server-side template injection: untrusted input becomes template source, not just data. */
|
|
446
|
+
// Bare `Template(...)` is excluded: Python's stdlib string.Template is safe
|
|
447
|
+
// substitution with no code execution, and callee text alone can't
|
|
448
|
+
// distinguish it from a Jinja2 Template — only jinja2.Template is unambiguous.
|
|
449
|
+
const SSTI_CALLEE_RE = /^(render_template_string|jinja2\.Template)\b/;
|
|
450
|
+
const ssti = (ctx) => {
|
|
451
|
+
const out = [];
|
|
452
|
+
walk(ctx.root, (n) => {
|
|
453
|
+
if (!isCall(n))
|
|
454
|
+
return;
|
|
455
|
+
const callee = calleeText(n) ?? "";
|
|
456
|
+
if (!SSTI_CALLEE_RE.test(callee))
|
|
457
|
+
return;
|
|
458
|
+
const argsNode = n.childForFieldName("arguments");
|
|
459
|
+
const firstArg = argsNode?.namedChild(0)?.text ?? "";
|
|
460
|
+
if (!firstArg)
|
|
461
|
+
return;
|
|
462
|
+
if (!isTainted(firstArg, ctx.taint) && !isUntrustedStringBuild(firstArg, ctx.taint))
|
|
463
|
+
return;
|
|
464
|
+
out.push(mkFinding(ctx, {
|
|
465
|
+
node: n,
|
|
466
|
+
detector: "ssti",
|
|
467
|
+
title: "Server-side template injection: untrusted input used as template source",
|
|
468
|
+
category: "classic",
|
|
469
|
+
severity: "critical",
|
|
470
|
+
confidence: 0.8,
|
|
471
|
+
reachable: true,
|
|
472
|
+
cwe: "CWE-1336",
|
|
473
|
+
owasp: "A03:2021 Injection",
|
|
474
|
+
mitre: "T1190",
|
|
475
|
+
why: "Untrusted input is compiled as a template (not just passed as data into one), letting an attacker inject template syntax that executes on the server.",
|
|
476
|
+
fix: "Never build a template string from user input. Pass untrusted data as render context/variables into a fixed template, not as the template source itself.",
|
|
477
|
+
taint: ["request input", "→ template source", `→ ${callee}()`],
|
|
478
|
+
}));
|
|
479
|
+
});
|
|
480
|
+
return out;
|
|
481
|
+
};
|
|
482
|
+
/**
|
|
483
|
+
* NoSQL injection: untrusted input used directly as (or inside) a Mongo-style
|
|
484
|
+
* query filter/operator object, allowing operator injection (e.g. {$ne: null}).
|
|
485
|
+
*/
|
|
486
|
+
const MONGO_QUERY_CALLEE_RE = /\.(find|findOne|findOneAndUpdate|findOneAndDelete|updateOne|updateMany|deleteOne|deleteMany|count|countDocuments)$/;
|
|
487
|
+
const MONGO_OPERATOR_RE = /\$where|\$ne|\$gt|\$regex/;
|
|
488
|
+
const nosqlInjection = (ctx) => {
|
|
489
|
+
const out = [];
|
|
490
|
+
walk(ctx.root, (n) => {
|
|
491
|
+
if (!isCall(n))
|
|
492
|
+
return;
|
|
493
|
+
const callee = calleeText(n) ?? "";
|
|
494
|
+
if (!MONGO_QUERY_CALLEE_RE.test(callee))
|
|
495
|
+
return;
|
|
496
|
+
const argsNode = n.childForFieldName("arguments");
|
|
497
|
+
const firstArg = argsNode?.namedChild(0) ?? null;
|
|
498
|
+
if (!firstArg || firstArg.type !== "object")
|
|
499
|
+
return; // only a literal filter object is analyzable
|
|
500
|
+
const filterText = firstArg.text;
|
|
501
|
+
// The whole request object/body passed straight through as the filter
|
|
502
|
+
// (e.g. find(req.body) or find({...req.query})) is the classic operator-
|
|
503
|
+
// injection pattern; a tainted scalar bound to a known key is normal use.
|
|
504
|
+
const wholeObjectTaint = isTainted(filterText, ctx.taint) && /\.\.\.|(^\{\s*req\b)/.test(filterText);
|
|
505
|
+
const operatorTaint = MONGO_OPERATOR_RE.test(filterText) && isTainted(filterText, ctx.taint);
|
|
506
|
+
if (!wholeObjectTaint && !operatorTaint)
|
|
507
|
+
return;
|
|
508
|
+
out.push(mkFinding(ctx, {
|
|
509
|
+
node: n,
|
|
510
|
+
detector: "nosql-injection",
|
|
511
|
+
title: "NoSQL injection: untrusted input builds the query filter object",
|
|
512
|
+
category: "classic",
|
|
513
|
+
severity: "critical",
|
|
514
|
+
confidence: 0.75,
|
|
515
|
+
reachable: true,
|
|
516
|
+
cwe: "CWE-943",
|
|
517
|
+
owasp: "A03:2021 Injection",
|
|
518
|
+
why: "Untrusted input is spread or forwarded directly into a MongoDB filter object. An attacker can inject operators like $ne/$gt/$where to bypass query logic (e.g. authentication) or run arbitrary JavaScript.",
|
|
519
|
+
fix: "Never pass a raw request object as a query filter. Pick out and validate individual fields, and reject values that are objects when a scalar is expected.",
|
|
520
|
+
taint: ["request input", "→ query filter object", `→ ${callee}()`],
|
|
521
|
+
}));
|
|
522
|
+
});
|
|
523
|
+
return out;
|
|
524
|
+
};
|
|
525
|
+
/**
|
|
526
|
+
* Weak crypto / insecure randomness in a security-sensitive context. AST-driven
|
|
527
|
+
* (matches a genuine call node, never text inside a string/comment/fixture) and
|
|
528
|
+
* scoped to calls whose enclosing statement mentions token/password/secret/
|
|
529
|
+
* session/auth vocabulary, so ordinary Math.random()/md5 use (UI ids, cache
|
|
530
|
+
* keys, checksums) isn't flagged.
|
|
531
|
+
*/
|
|
532
|
+
const SECURITY_CONTEXT_RE = /token|password|passwd|secret|session|apikey|api_key|otp|auth/i;
|
|
533
|
+
const STATEMENT_TYPES = new Set([
|
|
534
|
+
"variable_declarator", "assignment_expression", "assignment", "expression_statement",
|
|
535
|
+
"return_statement", "pair", "property", "keyword_argument", "dictionary",
|
|
536
|
+
]);
|
|
537
|
+
/** Nearest enclosing statement-ish node text, for judging security context around a call. */
|
|
538
|
+
function enclosingStatementText(n) {
|
|
539
|
+
let cur = n.parent;
|
|
540
|
+
for (let i = 0; i < 8 && cur; i++) {
|
|
541
|
+
if (STATEMENT_TYPES.has(cur.type))
|
|
542
|
+
return cur.text;
|
|
543
|
+
cur = cur.parent;
|
|
544
|
+
}
|
|
545
|
+
return n.parent?.text ?? n.text;
|
|
546
|
+
}
|
|
547
|
+
const weakCrypto = (ctx) => {
|
|
548
|
+
const out = [];
|
|
549
|
+
walk(ctx.root, (n) => {
|
|
550
|
+
if (!isCall(n))
|
|
551
|
+
return;
|
|
552
|
+
const callee = calleeText(n) ?? "";
|
|
553
|
+
if (callee === "Math.random") {
|
|
554
|
+
const context = enclosingStatementText(n);
|
|
555
|
+
if (!SECURITY_CONTEXT_RE.test(context))
|
|
556
|
+
return;
|
|
557
|
+
out.push(mkFinding(ctx, {
|
|
558
|
+
node: n,
|
|
559
|
+
detector: "weak-crypto",
|
|
560
|
+
title: "Insecure randomness: Math.random() used for a security-sensitive value",
|
|
561
|
+
category: "classic",
|
|
562
|
+
severity: "high",
|
|
563
|
+
confidence: 0.6,
|
|
564
|
+
reachable: true,
|
|
565
|
+
cwe: "CWE-330",
|
|
566
|
+
owasp: "A02:2021 Cryptographic Failures",
|
|
567
|
+
why: "Math.random() is not cryptographically secure and is predictable. Using it for tokens, passwords, or session identifiers lets an attacker guess or brute-force them.",
|
|
568
|
+
fix: "Use a CSPRNG: crypto.randomBytes()/crypto.randomUUID() (Node) or the secrets module (Python).",
|
|
569
|
+
}));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const firstArgText = n.childForFieldName("arguments")?.namedChild(0)?.text ?? "";
|
|
573
|
+
const isWeakHashCall = (/\bcreateHash$/.test(callee) && /^["'](md5|sha1)["']$/i.test(firstArgText)) ||
|
|
574
|
+
/^hashlib\.(md5|sha1)$/i.test(callee);
|
|
575
|
+
if (isWeakHashCall) {
|
|
576
|
+
const context = enclosingStatementText(n);
|
|
577
|
+
if (!SECURITY_CONTEXT_RE.test(context))
|
|
578
|
+
return;
|
|
579
|
+
out.push(mkFinding(ctx, {
|
|
580
|
+
node: n,
|
|
581
|
+
detector: "weak-crypto",
|
|
582
|
+
title: "Weak hash algorithm used for a security-sensitive value",
|
|
583
|
+
category: "classic",
|
|
584
|
+
severity: "high",
|
|
585
|
+
confidence: 0.6,
|
|
586
|
+
reachable: true,
|
|
587
|
+
cwe: "CWE-327",
|
|
588
|
+
owasp: "A02:2021 Cryptographic Failures",
|
|
589
|
+
why: "MD5 and SHA1 are broken for security purposes (collision attacks, fast brute force) and unsuitable for hashing passwords or secrets.",
|
|
590
|
+
fix: "Use a password hashing function (bcrypt/argon2/scrypt) for passwords, or SHA-256+ for integrity checks.",
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return out;
|
|
595
|
+
};
|
|
353
596
|
function lineNumberOf(code, re) {
|
|
354
597
|
const idx = code.search(re);
|
|
355
598
|
if (idx < 0)
|
|
@@ -364,4 +607,9 @@ export const classicDetectors = [
|
|
|
364
607
|
xss,
|
|
365
608
|
brokenAccessControl,
|
|
366
609
|
insecureConfig,
|
|
610
|
+
insecureDeserialization,
|
|
611
|
+
openRedirect,
|
|
612
|
+
ssti,
|
|
613
|
+
nosqlInjection,
|
|
614
|
+
weakCrypto,
|
|
367
615
|
];
|
package/dist/engine/fix/llm.js
CHANGED
|
@@ -11,18 +11,52 @@ export const LLM_DETECTORS = new Set([
|
|
|
11
11
|
"broken-access-control",
|
|
12
12
|
"sql-injection", // the shapes the deterministic fixer declined
|
|
13
13
|
]);
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Files larger than this are left to manual review (a single rewrite gets
|
|
16
|
+
* unreliable). Kept well under the 8192-token output cap below — a full-file
|
|
17
|
+
* rewrite needs roughly as many output tokens as the input has, plus room for
|
|
18
|
+
* the added fix — so this cap must stay smaller than max_tokens would allow.
|
|
19
|
+
*/
|
|
15
20
|
const MAX_FILE_CHARS = 16_000;
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const DEFAULT_MODEL = {
|
|
22
|
+
groq: "llama-3.3-70b-versatile",
|
|
23
|
+
anthropic: "claude-sonnet-4-6",
|
|
24
|
+
};
|
|
25
|
+
const KEY_ENV = {
|
|
26
|
+
groq: "GROQ_API_KEY",
|
|
27
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Which provider to use, if any: an explicit SECUREVIBE_LLM_PROVIDER wins (only
|
|
31
|
+
* if its key is present); else Groq when GROQ_API_KEY is set (the free-tier
|
|
32
|
+
* default); else Anthropic when ANTHROPIC_API_KEY is set; else none.
|
|
33
|
+
*/
|
|
34
|
+
function selectProvider() {
|
|
35
|
+
const forced = process.env.SECUREVIBE_LLM_PROVIDER?.toLowerCase();
|
|
36
|
+
if (forced === "groq" || forced === "anthropic") {
|
|
37
|
+
return process.env[KEY_ENV[forced]] ? forced : null;
|
|
38
|
+
}
|
|
39
|
+
if (process.env.GROQ_API_KEY)
|
|
40
|
+
return "groq";
|
|
41
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
42
|
+
return "anthropic";
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function modelFor(provider) {
|
|
46
|
+
return process.env.SECUREVIBE_MODEL || DEFAULT_MODEL[provider];
|
|
18
47
|
}
|
|
19
48
|
export function llmAvailability(disabled) {
|
|
20
|
-
const model = defaultModel();
|
|
21
49
|
if (disabled)
|
|
22
|
-
return { available: false, reason: "disabled (--no-llm)", model };
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
return { available: false, reason: "disabled (--no-llm)", model: "" };
|
|
51
|
+
const provider = selectProvider();
|
|
52
|
+
if (!provider) {
|
|
53
|
+
const forced = process.env.SECUREVIBE_LLM_PROVIDER?.toLowerCase();
|
|
54
|
+
const reason = forced === "groq" || forced === "anthropic"
|
|
55
|
+
? `SECUREVIBE_LLM_PROVIDER=${forced} but ${KEY_ENV[forced]} is not set`
|
|
56
|
+
: "no GROQ_API_KEY or ANTHROPIC_API_KEY";
|
|
57
|
+
return { available: false, reason, model: "" };
|
|
58
|
+
}
|
|
59
|
+
return { available: true, provider, model: modelFor(provider) };
|
|
26
60
|
}
|
|
27
61
|
const SYSTEM = [
|
|
28
62
|
"You are a senior application-security engineer fixing vulnerabilities in source code.",
|
|
@@ -40,23 +74,11 @@ const SYSTEM = [
|
|
|
40
74
|
"...full file...",
|
|
41
75
|
"</file>",
|
|
42
76
|
].join("\n");
|
|
43
|
-
|
|
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
|
-
}
|
|
77
|
+
function userPrompt(input) {
|
|
56
78
|
const findingList = input.findings
|
|
57
79
|
.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
80
|
.join("\n");
|
|
59
|
-
|
|
81
|
+
return [
|
|
60
82
|
`File: ${input.relPath} (${input.lang})`,
|
|
61
83
|
"",
|
|
62
84
|
"Findings to fix:",
|
|
@@ -67,29 +89,73 @@ export async function llmFix(input) {
|
|
|
67
89
|
input.code,
|
|
68
90
|
"</file>",
|
|
69
91
|
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
/** Returns the corrected file content, or null if unavailable / declined / unreliable. */
|
|
94
|
+
export async function llmFix(input) {
|
|
95
|
+
if (input.code.length > MAX_FILE_CHARS)
|
|
96
|
+
return null;
|
|
97
|
+
const provider = selectProvider();
|
|
98
|
+
if (!provider)
|
|
99
|
+
return null;
|
|
70
100
|
try {
|
|
71
|
-
const
|
|
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);
|
|
101
|
+
const text = provider === "groq" ? await callGroq(input) : await callAnthropic(input);
|
|
79
102
|
return parseFile(text, input.code);
|
|
80
103
|
}
|
|
81
104
|
catch {
|
|
82
105
|
return null; // network / auth / quota failure → fall back to manual
|
|
83
106
|
}
|
|
84
107
|
}
|
|
108
|
+
async function callAnthropic(input) {
|
|
109
|
+
let Anthropic;
|
|
110
|
+
try {
|
|
111
|
+
({ default: Anthropic } = await import("@anthropic-ai/sdk"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new Error("SDK not installed");
|
|
115
|
+
}
|
|
116
|
+
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
117
|
+
const msg = await client.messages.create({
|
|
118
|
+
model: modelFor("anthropic"),
|
|
119
|
+
max_tokens: 8192,
|
|
120
|
+
system: SYSTEM,
|
|
121
|
+
messages: [{ role: "user", content: userPrompt(input) }],
|
|
122
|
+
});
|
|
123
|
+
return extractAnthropicText(msg);
|
|
124
|
+
}
|
|
125
|
+
/** Groq's chat-completions endpoint is OpenAI-compatible — plain fetch, no SDK. */
|
|
126
|
+
async function callGroq(input) {
|
|
127
|
+
const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
model: modelFor("groq"),
|
|
135
|
+
max_tokens: 8192,
|
|
136
|
+
messages: [
|
|
137
|
+
{ role: "system", content: SYSTEM },
|
|
138
|
+
{ role: "user", content: userPrompt(input) },
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
throw new Error(`Groq API error: ${res.status}`);
|
|
144
|
+
const json = await res.json();
|
|
145
|
+
return extractGroqText(json);
|
|
146
|
+
}
|
|
85
147
|
/** Exported for unit testing the response plumbing without a live API call. */
|
|
86
|
-
export function
|
|
148
|
+
export function extractAnthropicText(msg) {
|
|
87
149
|
const blocks = Array.isArray(msg?.content) ? msg.content : [];
|
|
88
150
|
return blocks
|
|
89
151
|
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
90
152
|
.map((b) => b.text)
|
|
91
153
|
.join("");
|
|
92
154
|
}
|
|
155
|
+
/** Exported for unit testing the response plumbing without a live API call. */
|
|
156
|
+
export function extractGroqText(json) {
|
|
157
|
+
return json?.choices?.[0]?.message?.content ?? "";
|
|
158
|
+
}
|
|
93
159
|
/**
|
|
94
160
|
* Pull the rewritten file out of the response and sanity-check it. We reject a
|
|
95
161
|
* result that is suspiciously short relative to the original (likely truncated),
|
|
@@ -97,7 +97,7 @@ export async function runFix(root, options = {}) {
|
|
|
97
97
|
if (outcomes.some((o) => o.finding.id === f.id))
|
|
98
98
|
continue;
|
|
99
99
|
const note = LLM_DETECTORS.has(f.detector) && !llm.available
|
|
100
|
-
? `Needs an AI-assisted fix — set ANTHROPIC_API_KEY to auto-fix (currently ${llm.reason}). Manual: ${oneLine(f.fix)}`
|
|
100
|
+
? `Needs an AI-assisted fix — set GROQ_API_KEY (free tier) or ANTHROPIC_API_KEY to auto-fix (currently ${llm.reason}). Manual: ${oneLine(f.fix)}`
|
|
101
101
|
: `No safe automatic fix for this class yet. Manual: ${oneLine(f.fix)}`;
|
|
102
102
|
outcomes.push(manual(f, note));
|
|
103
103
|
}
|
|
@@ -136,6 +136,7 @@ export async function runFix(root, options = {}) {
|
|
|
136
136
|
root: absRoot,
|
|
137
137
|
apply: !!options.apply,
|
|
138
138
|
llmAvailable: llm.available,
|
|
139
|
+
llmProvider: llm.provider,
|
|
139
140
|
scoreBefore: before.score,
|
|
140
141
|
scoreAfter,
|
|
141
142
|
outcomes: sortOutcomes(outcomes),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const GATES = [
|
|
2
2
|
{ id: "secrets", label: "Secrets managed", detectors: ["hardcoded-secret"], crit: "blocker" },
|
|
3
3
|
{ id: "deps", label: "Dependencies current", detectors: ["vulnerable-dependency"], crit: "critical-only" },
|
|
4
|
-
{ id: "injection", label: "Injection safe (SQLi/RCE/SSRF)", detectors: ["sql-injection", "code-execution", "path-traversal", "ssrf"], crit: "blocker" },
|
|
4
|
+
{ id: "injection", label: "Injection safe (SQLi/RCE/SSRF/SSTI/NoSQL)", detectors: ["sql-injection", "code-execution", "path-traversal", "ssrf", "ssti", "nosql-injection", "insecure-deserialization"], crit: "blocker" },
|
|
5
5
|
{ id: "authz", label: "Access control", detectors: ["broken-access-control"], crit: "blocker" },
|
|
6
6
|
{ id: "ai", label: "AI agent safety", detectors: ["ai-tool-hijack", "ai-missing-firewall", "ai-prompt-injection", "ai-overprivileged-tool"], crit: "high-blocks" },
|
|
7
7
|
{ id: "transport", label: "Transport config (CORS/JWT)", detectors: ["cors-misconfig", "weak-jwt"], crit: "warning" },
|
package/dist/index.js
CHANGED
|
@@ -92,6 +92,34 @@ async function maybeNotifyUpdate(opts) {
|
|
|
92
92
|
if (latest)
|
|
93
93
|
process.stderr.write("\n" + updateNotice(VERSION, latest) + "\n");
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* If `fix` is about to run with no AI-fixer provider key available (and we're
|
|
97
|
+
* in an interactive terminal), offer to set one up on the spot — a free Groq
|
|
98
|
+
* key takes under a minute. Never prompts under --json/non-TTY/--no-llm, and
|
|
99
|
+
* never touches process.env if the user declines or a key is already set.
|
|
100
|
+
*/
|
|
101
|
+
async function maybeOfferAiFixerSetup() {
|
|
102
|
+
if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
|
|
103
|
+
return;
|
|
104
|
+
const { isInteractive, askLine } = await import("./ui/prompt.js");
|
|
105
|
+
if (!isInteractive())
|
|
106
|
+
return;
|
|
107
|
+
process.stderr.write("\n AI auto-fix (RCE, AI tool-hijack, IDOR, complex SQLi) needs a provider key — you don't have one set.\n");
|
|
108
|
+
const ans = (await askLine(" Enable it now with a free Groq key? [y/N]: ")).trim().toLowerCase();
|
|
109
|
+
if (ans !== "y" && ans !== "yes") {
|
|
110
|
+
process.stderr.write(" Skipping — those findings will show as manual review. Run `securevibe config set-key groq` anytime.\n\n");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const key = (await askLine(" Paste your Groq key (get one free at console.groq.com/keys): ")).trim();
|
|
114
|
+
if (!key) {
|
|
115
|
+
process.stderr.write(" No key entered — skipping.\n\n");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const { setKey, configFilePath } = await import("./config.js");
|
|
119
|
+
await setKey("groq", key);
|
|
120
|
+
process.env.GROQ_API_KEY = key;
|
|
121
|
+
process.stderr.write(` Saved to ${configFilePath()}. This run will use it.\n\n`);
|
|
122
|
+
}
|
|
95
123
|
/** Exit code policy for CI gating (doc 08/12). */
|
|
96
124
|
function ciExit(result, opts) {
|
|
97
125
|
if (!opts.ci)
|
|
@@ -135,17 +163,23 @@ program
|
|
|
135
163
|
.argument("[path]", "path to the repository", ".")
|
|
136
164
|
.option("--apply", "write the fixes to disk (asks before each change unless --yes)")
|
|
137
165
|
.option("--yes", "apply every verified fix without prompting (for CI / scripts)")
|
|
138
|
-
.option("--no-llm", "disable the optional
|
|
166
|
+
.option("--no-llm", "disable the optional AI-powered fixer (Groq or Claude)")
|
|
139
167
|
.option("--json", "output machine-readable JSON")
|
|
140
168
|
.option("--no-color", "disable coloured output")
|
|
141
169
|
.action(async (pathArg, opts) => {
|
|
142
170
|
if (opts.color === false)
|
|
143
171
|
process.env.NO_COLOR = "1";
|
|
144
|
-
await
|
|
145
|
-
|
|
172
|
+
const { applyStoredKeysToEnv } = await import("./config.js");
|
|
173
|
+
await applyStoredKeysToEnv();
|
|
146
174
|
const { isInteractive, decideApply, makeFixConfirm } = await import("./ui/prompt.js");
|
|
147
|
-
const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
|
|
148
175
|
const decision = decideApply(opts, isInteractive());
|
|
176
|
+
// Only worth asking on a run that will actually write something — a dry-run
|
|
177
|
+
// preview has no use for a key yet, and asking anyway reads as nagging.
|
|
178
|
+
if (opts.llm !== false && !opts.json && decision.apply)
|
|
179
|
+
await maybeOfferAiFixerSetup();
|
|
180
|
+
const usage = await meter();
|
|
181
|
+
const { runFix } = await import("./engine/fix/session.js");
|
|
182
|
+
const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
|
|
149
183
|
const result = await runFix(pathArg ?? ".", {
|
|
150
184
|
apply: decision.apply,
|
|
151
185
|
noLlm: opts.llm === false,
|
|
@@ -155,7 +189,7 @@ program
|
|
|
155
189
|
if (opts.json)
|
|
156
190
|
process.stdout.write(fixToJson(result) + "\n");
|
|
157
191
|
else
|
|
158
|
-
process.stdout.write(renderFixSession(result) + "\n");
|
|
192
|
+
process.stdout.write(renderFixSession(result, usage) + "\n");
|
|
159
193
|
if (decision.notice) {
|
|
160
194
|
process.stderr.write(" Not applied: no interactive terminal. Re-run with --yes to apply non-interactively.\n");
|
|
161
195
|
}
|
|
@@ -267,6 +301,57 @@ program
|
|
|
267
301
|
process.stderr.write(` unknown db action: ${action} (use: update | status)\n`);
|
|
268
302
|
process.exit(64);
|
|
269
303
|
});
|
|
304
|
+
program
|
|
305
|
+
.command("config")
|
|
306
|
+
.description("Manage local AI-fixer provider keys (Groq/Anthropic), stored in ~/.securevibe/config.json")
|
|
307
|
+
.argument("<action>", "show | set-key | unset-key")
|
|
308
|
+
.argument("[provider]", "groq | anthropic")
|
|
309
|
+
.option("--no-color", "disable coloured output")
|
|
310
|
+
.action(async (action, provider, opts) => {
|
|
311
|
+
if (opts.color === false)
|
|
312
|
+
process.env.NO_COLOR = "1";
|
|
313
|
+
const { readConfig, setKey, unsetKey, maskKey, configFilePath, ENV_VAR } = await import("./config.js");
|
|
314
|
+
const { isInteractive, askLine } = await import("./ui/prompt.js");
|
|
315
|
+
if (action === "show") {
|
|
316
|
+
const cfg = await readConfig();
|
|
317
|
+
const line = (label, stored, envSet) => ` ${label}:`.padEnd(13) +
|
|
318
|
+
(stored ? maskKey(stored) : "not set") +
|
|
319
|
+
(envSet ? " (env var active, overrides the stored key)" : "");
|
|
320
|
+
process.stdout.write(" SecureVibe AI-fixer keys\n");
|
|
321
|
+
process.stdout.write(` file: ${configFilePath()}\n`);
|
|
322
|
+
process.stdout.write(line("groq", cfg.groqApiKey, !!process.env.GROQ_API_KEY) + "\n");
|
|
323
|
+
process.stdout.write(line("anthropic", cfg.anthropicApiKey, !!process.env.ANTHROPIC_API_KEY) + "\n");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (action === "set-key" || action === "unset-key") {
|
|
327
|
+
if (provider !== "groq" && provider !== "anthropic") {
|
|
328
|
+
process.stderr.write(` usage: securevibe config ${action} <groq|anthropic>\n`);
|
|
329
|
+
process.exit(64);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (action === "set-key") {
|
|
333
|
+
if (!isInteractive()) {
|
|
334
|
+
process.stderr.write(" set-key needs an interactive terminal — a key is never accepted as a command argument, to keep it out of shell history.\n");
|
|
335
|
+
process.exit(64);
|
|
336
|
+
}
|
|
337
|
+
const hint = provider === "groq" ? " (free — get one at console.groq.com/keys)" : " (from console.anthropic.com)";
|
|
338
|
+
const key = (await askLine(` Paste your ${provider} API key${hint}: `)).trim();
|
|
339
|
+
if (!key) {
|
|
340
|
+
process.stderr.write(" No key entered — nothing saved.\n");
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
await setKey(provider, key);
|
|
344
|
+
process.stdout.write(` Saved to ${configFilePath()}. Used automatically by \`fix\` whenever ${ENV_VAR[provider]} isn't already set in your shell.\n`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (action === "unset-key") {
|
|
348
|
+
await unsetKey(provider);
|
|
349
|
+
process.stdout.write(` Removed the stored ${provider} key.\n`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
process.stderr.write(` unknown config action: ${action} (use: show | set-key | unset-key)\n`);
|
|
353
|
+
process.exit(64);
|
|
354
|
+
});
|
|
270
355
|
program.parseAsync(process.argv).catch((err) => {
|
|
271
356
|
process.stderr.write(`securevibe: ${err?.message ?? err}\n`);
|
|
272
357
|
process.exit(70);
|
package/dist/ui/fix.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import { structuredPatch } from "diff";
|
|
9
9
|
import { renderBanner } from "./banner.js";
|
|
10
|
+
import { renderUsageLine } from "./usage.js";
|
|
10
11
|
const SEV_TAG = {
|
|
11
12
|
critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
|
|
12
13
|
high: (s) => pc.red(pc.bold(s)),
|
|
@@ -31,10 +32,14 @@ function readinessLabel(r) {
|
|
|
31
32
|
function scoreBadge(s) {
|
|
32
33
|
return `${gradeColor(s.grade)(pc.bold(` ${s.grade} `))} ${s.composite}/100 ${readinessLabel(s.readiness)}`;
|
|
33
34
|
}
|
|
34
|
-
export function renderFixSession(result) {
|
|
35
|
+
export function renderFixSession(result, usage) {
|
|
35
36
|
const L = [];
|
|
36
37
|
L.push(renderBanner({ subtitle: result.apply ? "fix · applying" : "fix · dry run (no files changed)" }));
|
|
37
38
|
L.push("");
|
|
39
|
+
if (usage) {
|
|
40
|
+
L.push(renderUsageLine(usage));
|
|
41
|
+
L.push("");
|
|
42
|
+
}
|
|
38
43
|
// Score transition.
|
|
39
44
|
L.push(" " + scoreBadge(result.scoreBefore) + pc.dim(" → ") + scoreBadge(result.scoreAfter));
|
|
40
45
|
L.push("");
|
|
@@ -50,8 +55,10 @@ export function renderFixSession(result) {
|
|
|
50
55
|
if (skipped.length > 0)
|
|
51
56
|
counts.push(pc.dim(`${skipped.length} skipped`));
|
|
52
57
|
counts.push(pc.yellow(`${manual.length} need manual review`));
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const aiFixerLabel = result.llmAvailable
|
|
59
|
+
? `AI fixer: on (${result.llmProvider === "groq" ? "Groq" : "Claude"})`
|
|
60
|
+
: "AI fixer: off";
|
|
61
|
+
L.push(" " + counts.join(" · ") + pc.dim(` · ${aiFixerLabel}`));
|
|
55
62
|
L.push("");
|
|
56
63
|
// Diffs for accepted patches.
|
|
57
64
|
for (const p of result.patches) {
|
|
@@ -107,7 +114,7 @@ export function renderFixSession(result) {
|
|
|
107
114
|
L.push(pc.dim(" These fixes mean the detector no longer flags the issue — not that the app is proven secure. " +
|
|
108
115
|
"Review every diff and run your tests before deploying."));
|
|
109
116
|
if (!result.llmAvailable && result.outcomes.some((o) => o.status === "manual")) {
|
|
110
|
-
L.push(pc.dim(" Set ANTHROPIC_API_KEY to auto-fix logic-level issues (RCE, AI tool hijack, IDOR)."));
|
|
117
|
+
L.push(pc.dim(" Set GROQ_API_KEY (free tier) or ANTHROPIC_API_KEY to auto-fix logic-level issues (RCE, AI tool hijack, IDOR)."));
|
|
111
118
|
}
|
|
112
119
|
L.push("");
|
|
113
120
|
return L.join("\n");
|
package/dist/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Single source of truth for the CLI's own version, so it can't drift from
|
|
3
3
|
// what commander reports and what the update-check compares against.
|
|
4
4
|
// Kept in sync with the "version" field in package.json by hand at release time.
|
|
5
|
-
export const VERSION = "0.1.
|
|
5
|
+
export const VERSION = "0.1.7";
|
package/package.json
CHANGED