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 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
- - **Claude-powered (optional, set `ANTHROPIC_API_KEY`):** *targets* the logic-level classes a
74
- rule can't safely rewrite — RCE/command-injection, AI tool-hijack / missing firewall, IDOR,
75
- DOM XSS, and complex SQL. One rewrite per file; the result is kept **only if it passes the
76
- verification gate**, otherwise it falls back to a precise manual instruction.
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 **live Claude path is wired
86
- and unit-tested (response parsing, gate behavior) but has not been exercised against a real API
87
- key in this build** — set `ANTHROPIC_API_KEY` and review the diffs it proposes.
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
  ];
@@ -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
- /** Files larger than this are left to manual review (a single rewrite gets unreliable). */
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
- export function defaultModel() {
17
- return process.env.SECUREVIBE_MODEL || "claude-sonnet-4-6";
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
- if (!process.env.ANTHROPIC_API_KEY)
24
- return { available: false, reason: "no ANTHROPIC_API_KEY", model };
25
- return { available: true, model };
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
- /** 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
- }
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
- const user = [
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 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);
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 extractText(msg) {
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 Claude-powered fixer")
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 meter();
145
- const { runFix } = await import("./engine/fix/session.js");
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
- L.push(" " + counts.join(" · ") +
54
- (result.llmAvailable ? pc.dim(" · Claude fixer: on") : pc.dim(" · Claude fixer: off")));
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.3";
5
+ export const VERSION = "0.1.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
4
4
  "description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",