securevibe 0.1.8 → 0.1.10

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
@@ -27,6 +27,7 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
27
27
  | `securevibe scan [path]` | Full scan → findings + security score (default command) |
28
28
  | `securevibe init [path]` | **Wire SecureVibe in: pre-commit guard + GitHub Action + gitignore** (doc 12) |
29
29
  | `securevibe fix [path]` | **Autonomously fix findings, then re-scan to verify** (doc 05) |
30
+ | `securevibe explain [path]` | AI-powered plain-language explanations for critical/high findings |
30
31
  | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
31
32
  | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
32
33
  | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
@@ -78,11 +79,20 @@ Anything that fails the gate is rolled back and surfaced as a manual instruction
78
79
  is kept **only if it passes the verification gate**, otherwise it falls back to a precise manual
79
80
  instruction. Groq is used by default when both keys are present (lower bar to try it at all);
80
81
  set `SECUREVIBE_LLM_PROVIDER=anthropic` to force Claude instead.
81
- - **Setting a key:** either export it yourself (`GROQ_API_KEY=...`), or run
82
- `securevibe config set-key groq` it prompts interactively (never as a command argument, so it
83
- never lands in shell history) and saves it to `~/.securevibe/config.json`, used automatically by
84
- `fix` whenever the env var isn't already set. Run `fix --apply` with no key configured and it will
85
- offer to set one up on the spot. `securevibe config show` lists what's stored (masked).
82
+ - **Setting a key:** `securevibe init` is the primary path — if it detects an LLM SDK dependency
83
+ (openai, `@anthropic-ai/sdk`, langchain, ...) and no key is already set, it offers to set one up
84
+ on the spot, once per project (declining is remembered so it won't ask again). You can also
85
+ export a key yourself (`GROQ_API_KEY=...`), or run `securevibe config set-key groq` any time it
86
+ prompts interactively (never as a command argument, so it never lands in shell history) and saves
87
+ it to `~/.securevibe/config.json`, used automatically whenever the env var isn't already set.
88
+ - **A second AI feature, `securevibe explain`:** for critical/high findings, asks
89
+ the same configured provider for a plain-language explanation grounded in the
90
+ actual flagged code (not just the generic why/fix text every finding of that
91
+ detector shares). Capped at the top 10 qualifying findings per run. Falls back to
92
+ the static why/fix text with no key configured, or if a call fails — an
93
+ explanation is always additional context, never required to use the command.
94
+ `fix --apply` with no key configured still offers to set one up as a fallback.
95
+ `securevibe config show` lists what's stored (masked).
86
96
 
87
97
  **Honesty (read this):**
88
98
  - A "fixed" finding means *our detector no longer flags it* — not that the app is proven secure.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `securevibe explain` (AI feature #2, 2026-07-10). Turns a critical/high
3
+ * finding's generic why/fix text into a plain-language explanation grounded
4
+ * in the actual flagged code. Read-only — no files are modified, so there is
5
+ * no verification/rollback gate the way `fix` needs one.
6
+ *
7
+ * Capped at EXPLAIN_MAX_FINDINGS per run to bound worst-case AI cost/latency:
8
+ * a scan with dozens of criticals should never turn into dozens of API calls
9
+ * without the caller asking for it. Degrades gracefully with no provider
10
+ * configured (or a failed call) — every finding still gets a result, with
11
+ * explanation: null, so the UI layer falls back to the static why/fix text.
12
+ */
13
+ import { promises as fs } from "node:fs";
14
+ import path from "node:path";
15
+ import { SEVERITY_ORDER } from "./types.js";
16
+ import { llmChat, llmAvailability } from "./fix/llm.js";
17
+ export const EXPLAIN_MAX_FINDINGS = 10;
18
+ const EXPLAIN_SEVERITIES = new Set(["critical", "high"]);
19
+ const SNIPPET_CONTEXT_LINES = 4;
20
+ const EXPLAIN_SYSTEM = [
21
+ "You are a senior application-security engineer explaining one specific vulnerability finding to the developer who wrote the code.",
22
+ "You will receive the finding's detector, severity, generic why/fix guidance, and a snippet of the actual flagged code (the vulnerable line is marked with >>).",
23
+ "Write a short plain-language explanation (3-5 sentences) of the concrete risk IN THIS CODE specifically — reference the actual variable, function, or endpoint names visible in the snippet, not generic advice.",
24
+ "Do not repeat the generic why/fix text verbatim. Do not add a heading, bullet list, or markdown formatting. Return prose only.",
25
+ ].join("\n");
26
+ function explainUserPrompt(finding, snippet) {
27
+ return [
28
+ `Finding: ${finding.title}`,
29
+ `Detector: ${finding.detector} Severity: ${finding.severity}${finding.cwe ? ` ${finding.cwe}` : ""}`,
30
+ `File: ${finding.file}:${finding.line}`,
31
+ "",
32
+ "Generic why:",
33
+ finding.why,
34
+ "",
35
+ "Generic fix:",
36
+ finding.fix,
37
+ "",
38
+ "Code snippet (flagged line marked with >>):",
39
+ snippet,
40
+ ].join("\n");
41
+ }
42
+ /** A fixed window of lines around `line` (1-indexed), clamped to file bounds. */
43
+ export function extractSnippet(code, line) {
44
+ const lines = code.split(/\r?\n/);
45
+ const idx = Math.min(Math.max(line - 1, 0), lines.length - 1);
46
+ const start = Math.max(0, idx - SNIPPET_CONTEXT_LINES);
47
+ const end = Math.min(lines.length, idx + SNIPPET_CONTEXT_LINES + 1);
48
+ const out = [];
49
+ for (let i = start; i < end; i++) {
50
+ out.push(`${i === idx ? ">>" : " "} ${i + 1}: ${lines[i]}`);
51
+ }
52
+ return out.join("\n");
53
+ }
54
+ export async function explainFindings(scanResult, opts = {}) {
55
+ const qualifying = scanResult.findings
56
+ .filter((f) => EXPLAIN_SEVERITIES.has(f.severity))
57
+ .sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
58
+ const capped = qualifying.slice(0, EXPLAIN_MAX_FINDINGS);
59
+ const truncatedCount = qualifying.length - capped.length;
60
+ const avail = llmAvailability(false);
61
+ const results = [];
62
+ for (const finding of capped) {
63
+ opts.onProgress?.(`Explaining ${finding.file}:${finding.line}…`);
64
+ let explanation = null;
65
+ if (avail.available) {
66
+ let snippet;
67
+ try {
68
+ const code = await fs.readFile(path.join(scanResult.root, finding.file), "utf8");
69
+ snippet = extractSnippet(code, finding.line);
70
+ }
71
+ catch {
72
+ snippet = "(source unavailable)";
73
+ }
74
+ explanation = await llmChat(EXPLAIN_SYSTEM, explainUserPrompt(finding, snippet));
75
+ }
76
+ results.push({ finding, explanation });
77
+ }
78
+ return { root: scanResult.root, results, truncatedCount, llmAvailable: avail.available };
79
+ }
@@ -94,18 +94,30 @@ function userPrompt(input) {
94
94
  export async function llmFix(input) {
95
95
  if (input.code.length > MAX_FILE_CHARS)
96
96
  return null;
97
+ const text = await llmChat(SYSTEM, userPrompt(input));
98
+ if (text == null)
99
+ return null;
100
+ return parseFile(text, input.code);
101
+ }
102
+ /**
103
+ * Generic single-turn chat call against whichever provider is configured.
104
+ * Shared by llmFix (full-file rewrites) and engine/explain.ts (plain-language
105
+ * finding explanations) so the Groq/Anthropic HTTP plumbing lives in one place.
106
+ * Returns null on no-provider / network / auth / quota failure — callers degrade
107
+ * gracefully and must never throw because of this.
108
+ */
109
+ export async function llmChat(system, user) {
97
110
  const provider = selectProvider();
98
111
  if (!provider)
99
112
  return null;
100
113
  try {
101
- const text = provider === "groq" ? await callGroq(input) : await callAnthropic(input);
102
- return parseFile(text, input.code);
114
+ return provider === "groq" ? await callGroq(system, user) : await callAnthropic(system, user);
103
115
  }
104
116
  catch {
105
- return null; // network / auth / quota failure → fall back to manual
117
+ return null;
106
118
  }
107
119
  }
108
- async function callAnthropic(input) {
120
+ async function callAnthropic(system, user) {
109
121
  let Anthropic;
110
122
  try {
111
123
  ({ default: Anthropic } = await import("@anthropic-ai/sdk"));
@@ -117,13 +129,13 @@ async function callAnthropic(input) {
117
129
  const msg = await client.messages.create({
118
130
  model: modelFor("anthropic"),
119
131
  max_tokens: 8192,
120
- system: SYSTEM,
121
- messages: [{ role: "user", content: userPrompt(input) }],
132
+ system,
133
+ messages: [{ role: "user", content: user }],
122
134
  });
123
135
  return extractAnthropicText(msg);
124
136
  }
125
137
  /** Groq's chat-completions endpoint is OpenAI-compatible — plain fetch, no SDK. */
126
- async function callGroq(input) {
138
+ async function callGroq(system, user) {
127
139
  const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
128
140
  method: "POST",
129
141
  headers: {
@@ -134,8 +146,8 @@ async function callGroq(input) {
134
146
  model: modelFor("groq"),
135
147
  max_tokens: 8192,
136
148
  messages: [
137
- { role: "system", content: SYSTEM },
138
- { role: "user", content: userPrompt(input) },
149
+ { role: "system", content: system },
150
+ { role: "user", content: user },
139
151
  ],
140
152
  }),
141
153
  });
@@ -9,6 +9,43 @@
9
9
  import { promises as fs } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { isGitRepo, gitDir } from "./git.js";
12
+ export function projectConfigPath(root) {
13
+ return path.join(path.resolve(root), "securevibe.config.json");
14
+ }
15
+ /** Tolerant read: a missing or corrupt config is treated as empty. */
16
+ export async function readProjectConfig(root) {
17
+ try {
18
+ const raw = await fs.readFile(projectConfigPath(root), "utf8");
19
+ const parsed = JSON.parse(raw);
20
+ return typeof parsed === "object" && parsed ? parsed : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ export async function patchProjectConfig(root, patch) {
27
+ const current = await readProjectConfig(root);
28
+ const next = { ...current, ...patch };
29
+ await fs.writeFile(projectConfigPath(root), JSON.stringify(next, null, 2) + "\n", "utf8");
30
+ }
31
+ // Package names/imports that mark a repo as "uses an LLM SDK" — matched against
32
+ // package.json (dependency keys) and requirements.txt (package names) so the
33
+ // init-time AI-fixer offer only fires where it's actually relevant.
34
+ const AI_PACKAGE_RE = /"(openai|@anthropic-ai\/sdk|anthropic|langchain|@langchain\/[\w.-]+|cohere-ai|@google\/generative-ai|llamaindex|@modelcontextprotocol\/sdk)"\s*:|^\s*(openai|anthropic|langchain|cohere|google-generativeai|llama-index)\b/im;
35
+ export async function looksAiRelated(root) {
36
+ const absRoot = path.resolve(root);
37
+ for (const rel of ["package.json", "requirements.txt"]) {
38
+ try {
39
+ const content = await fs.readFile(path.join(absRoot, rel), "utf8");
40
+ if (AI_PACKAGE_RE.test(content))
41
+ return true;
42
+ }
43
+ catch {
44
+ /* file absent — try the next */
45
+ }
46
+ }
47
+ return false;
48
+ }
12
49
  const GITIGNORE_LINES = [".env", ".env.local", ".env.*.local", ".securevibe-backup/"];
13
50
  const CONFIG = `{
14
51
  "failOn": "block",
@@ -30,3 +30,32 @@ export async function maybeOfferAiFixerSetup() {
30
30
  process.env.GROQ_API_KEY = key;
31
31
  process.stderr.write(` Saved to ${configFilePath()}. This run will use it.\n\n`);
32
32
  }
33
+ /**
34
+ * Runs once at the end of `init`, interactive-only. Users were learning a
35
+ * provider key was needed only mid-`fix` — too late, past the point of
36
+ * expecting the fixer to already work. `init` already runs once per project
37
+ * before any `fix`, so it's the right place to surface this early — but only
38
+ * for repos that actually use an LLM SDK, and only once (a decline is
39
+ * remembered per-project so we don't nag).
40
+ */
41
+ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
42
+ const { applyStoredKeysToEnv } = await import("./config.js");
43
+ await applyStoredKeysToEnv(deps?.configFile);
44
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
45
+ return;
46
+ const { readProjectConfig, patchProjectConfig, looksAiRelated } = await import("./engine/init.js");
47
+ const cfg = await readProjectConfig(root);
48
+ if (cfg.aiFixerPromptDismissed)
49
+ return;
50
+ if (!(await looksAiRelated(root)))
51
+ return;
52
+ const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
53
+ if (!isInteractive())
54
+ return;
55
+ process.stderr.write("\n This project uses an LLM SDK — SecureVibe can auto-fix AI-specific findings\n (tool-hijack, prompt-injection sinks) with a free key.\n");
56
+ const offer = deps?.offer ?? maybeOfferAiFixerSetup;
57
+ await offer();
58
+ if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
59
+ await patchProjectConfig(root, { aiFixerPromptDismissed: true });
60
+ }
61
+ }
package/dist/index.js CHANGED
@@ -128,6 +128,8 @@ program
128
128
  const { renderInit } = await import("./ui/init.js");
129
129
  const result = await runInit(pathArg ?? ".", { force: opts.force });
130
130
  process.stdout.write(renderInit(result) + "\n");
131
+ const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
132
+ await maybeOfferAiFixerSetupDuringInit(pathArg ?? ".");
131
133
  });
132
134
  program
133
135
  .command("fix")
@@ -169,6 +171,36 @@ program
169
171
  }
170
172
  await maybeNotifyUpdate({ json: opts.json });
171
173
  });
174
+ program
175
+ .command("explain")
176
+ .description("AI-powered plain-language explanations for critical/high findings")
177
+ .argument("[path]", "path to the repository", ".")
178
+ .option("--json", "output machine-readable JSON")
179
+ .option("--no-color", "disable coloured output")
180
+ .action(async (pathArg, opts) => {
181
+ if (opts.color === false)
182
+ process.env.NO_COLOR = "1";
183
+ const { applyStoredKeysToEnv } = await import("./config.js");
184
+ await applyStoredKeysToEnv();
185
+ if (!opts.json) {
186
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
187
+ await maybeOfferAiFixerSetup();
188
+ }
189
+ const { result, usage } = await runScan(pathArg, { json: opts.json, color: opts.color });
190
+ const { explainFindings } = await import("./engine/explain.js");
191
+ const report = await explainFindings(result, {
192
+ onProgress: opts.json ? undefined : (m) => process.stderr.write(` · ${m}\n`),
193
+ });
194
+ if (opts.json) {
195
+ const { explainToJson } = await import("./ui/explain.js");
196
+ process.stdout.write(explainToJson(report) + "\n");
197
+ }
198
+ else {
199
+ const { renderExplain } = await import("./ui/explain.js");
200
+ process.stdout.write(renderExplain(report, usage) + "\n");
201
+ }
202
+ await maybeNotifyUpdate({ json: opts.json });
203
+ });
172
204
  addCommon(program
173
205
  .command("ai-audit")
174
206
  .description("Focus on the AI-application / agent attack surface (doc 04)")
package/dist/repl.js CHANGED
@@ -44,6 +44,7 @@ const HELP = [
44
44
  " Commands:",
45
45
  " scan [--json] full scan",
46
46
  " fix [--apply] [--yes] [--no-llm] [--json] fix findings",
47
+ " explain [--json] AI explanations for critical/high findings",
47
48
  " ai-audit | protect | attack-map | score | deps | ready [--json]",
48
49
  " init [--force] wire up the pre-commit guard + CI",
49
50
  " config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
@@ -149,11 +150,35 @@ async function dispatch(cmd, tokens, state, io) {
149
150
  }
150
151
  return;
151
152
  }
153
+ if (cmd === "explain") {
154
+ if (!asJson) {
155
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
156
+ await maybeOfferAiFixerSetup();
157
+ }
158
+ const { explainFindings } = await import("./engine/explain.js");
159
+ const usage = await meterOrPrint(io);
160
+ const { scan } = await import("./engine/scan.js");
161
+ const scanResult = await scan(state.root, { onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`) });
162
+ state.lastResult = scanResult;
163
+ const report = await explainFindings(scanResult, {
164
+ onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
165
+ });
166
+ if (asJson) {
167
+ const { explainToJson } = await import("./ui/explain.js");
168
+ write(io, explainToJson(report) + "\n\n");
169
+ return;
170
+ }
171
+ const { renderExplain } = await import("./ui/explain.js");
172
+ write(io, renderExplain(report, usage) + "\n");
173
+ return;
174
+ }
152
175
  if (cmd === "init") {
153
176
  const { runInit } = await import("./engine/init.js");
154
177
  const { renderInit } = await import("./ui/init.js");
155
178
  const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
156
179
  write(io, renderInit(result) + "\n");
180
+ const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
181
+ await maybeOfferAiFixerSetupDuringInit(state.root);
157
182
  return;
158
183
  }
159
184
  if (cmd === "config") {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `explain` view. Shows the AI-generated explanation per finding when
3
+ * available, falling back to the existing static why/fix text otherwise —
4
+ * an AI explanation is additional context, never a replacement, so the
5
+ * static text stays a guaranteed field in both the human and JSON output.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
10
+ const SEV_TAG = {
11
+ critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
12
+ high: (s) => pc.red(pc.bold(s)),
13
+ medium: (s) => pc.yellow(s),
14
+ low: (s) => pc.blue(s),
15
+ info: (s) => pc.dim(s),
16
+ };
17
+ export function renderExplain(report, usage) {
18
+ const L = [];
19
+ L.push(renderBanner({ subtitle: "AI-powered finding explanations" }));
20
+ L.push(pc.dim(` ${report.root}`));
21
+ L.push("");
22
+ if (usage) {
23
+ L.push(renderUsageLine(usage));
24
+ L.push("");
25
+ }
26
+ if (report.results.length === 0) {
27
+ L.push(pc.green(" No critical/high findings to explain."));
28
+ L.push("");
29
+ return L.join("\n");
30
+ }
31
+ for (const r of report.results) {
32
+ const f = r.finding;
33
+ const sev = SEV_TAG[f.severity](` ${f.severity.toUpperCase()} `);
34
+ L.push(` ${sev} ${pc.bold(f.title)} ${pc.dim(`(${f.file}:${f.line})`)}`);
35
+ if (r.explanation) {
36
+ L.push(` ${pc.cyan("AI:")} ${r.explanation.trim()}`);
37
+ }
38
+ else {
39
+ L.push(` ${pc.dim("why:")} ${f.why}`);
40
+ L.push(` ${pc.dim("fix:")} ${f.fix}`);
41
+ }
42
+ L.push("");
43
+ }
44
+ if (report.truncatedCount > 0) {
45
+ const total = report.results.length + report.truncatedCount;
46
+ L.push(pc.dim(` Showing the top ${report.results.length} of ${total} critical/high findings — re-run \`scan\` to see the rest.`));
47
+ L.push("");
48
+ }
49
+ if (!report.llmAvailable) {
50
+ L.push(pc.dim(" Set GROQ_API_KEY (free tier) or ANTHROPIC_API_KEY for AI-generated explanations — showing static guidance above. Run `securevibe config set-key groq`."));
51
+ L.push("");
52
+ }
53
+ return L.join("\n");
54
+ }
55
+ export function explainToJson(report) {
56
+ return JSON.stringify({
57
+ root: report.root,
58
+ llmAvailable: report.llmAvailable,
59
+ truncatedCount: report.truncatedCount,
60
+ results: report.results.map((r) => ({
61
+ id: r.finding.id,
62
+ detector: r.finding.detector,
63
+ severity: r.finding.severity,
64
+ file: r.finding.file,
65
+ line: r.finding.line,
66
+ title: r.finding.title,
67
+ why: r.finding.why,
68
+ fix: r.finding.fix,
69
+ explanation: r.explanation,
70
+ })),
71
+ }, null, 2);
72
+ }
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.8";
5
+ export const VERSION = "0.1.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",