securevibe 0.1.8 → 0.1.12

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,8 +27,10 @@ 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 |
31
+ | `securevibe pr-comment [path]` | Post/update a PR findings summary (used by the GitHub Actions workflow) |
30
32
  | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
31
- | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
33
+ | `securevibe db <update\|status>` | Manage the local OSV database — `update` is networked |
32
34
  | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
33
35
  | `securevibe ai-audit [path]` | Focus on the AI-agent attack surface (doc 04) |
34
36
  | `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
@@ -39,11 +41,19 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
39
41
 
40
42
  Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
41
43
  `--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
42
- `--staged` (scan only git-staged files — what the pre-commit guard uses).
44
+ `--staged` (scan only git-staged files — what the pre-commit guard uses),
45
+ `--diff <base>` (scan only files changed since `<base>` — what the PR-comment step
46
+ uses; also exempt from the daily usage limit, same as `--staged`).
43
47
  `fix` flags: `--apply` (write to disk; asks before each change), `--yes` (apply every
44
48
  verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-only), `--json`.
45
49
  `init` flags: `--force` (replace an existing hook/workflow/config).
46
50
 
51
+ The REPL renders a bordered input box with a live status line (target + active AI-fixer
52
+ provider). Type `/` to open a command-discovery menu, or type commands directly as
53
+ before (`scan`, `fix --apply`, `help`, etc. — the `/` prefix is optional, not required).
54
+ Up/down arrow cycles through your command history. `scan`/`fix`/`explain` show a single
55
+ live progress spinner instead of scrolling status lines.
56
+
47
57
  ## Always-on (doc 12)
48
58
 
49
59
  `securevibe init` makes the tool continuous so insecure code can't slip through:
@@ -52,11 +62,16 @@ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-on
52
62
  a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
53
63
  clean version through);
54
64
  - a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
55
- SARIF to GitHub code scanning, gating the build on deployment-readiness;
65
+ SARIF to GitHub code scanning, gates the build on deployment-readiness, and posts a PR comment
66
+ summarizing findings in the *changed* files only (updated in place on new commits, not reposted —
67
+ and the only other command besides `db update` that talks to the network, and only to post a
68
+ findings summary already computed locally, never your source code);
56
69
  - gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
57
70
 
58
- It is idempotent (existing files are left alone unless `--force`) and runs entirely locally —
59
- **no account, no upload, and your secrets / API keys are never stored or transmitted.**
71
+ It is idempotent (existing files are left alone unless `--force`). Everything it installs runs
72
+ locally — **no account, and your secrets / API keys are never stored or transmitted.** The one
73
+ exception is the PR-comment step above, which uploads a findings summary (never your source code)
74
+ already computed by the local scan.
60
75
 
61
76
  ## The fix loop (doc 05)
62
77
 
@@ -78,11 +93,20 @@ Anything that fails the gate is rolled back and surfaced as a manual instruction
78
93
  is kept **only if it passes the verification gate**, otherwise it falls back to a precise manual
79
94
  instruction. Groq is used by default when both keys are present (lower bar to try it at all);
80
95
  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).
96
+ - **Setting a key:** `securevibe init` is the primary path — if it detects an LLM SDK dependency
97
+ (openai, `@anthropic-ai/sdk`, langchain, ...) and no key is already set, it offers to set one up
98
+ on the spot, once per project (declining is remembered so it won't ask again). You can also
99
+ export a key yourself (`GROQ_API_KEY=...`), or run `securevibe config set-key groq` any time it
100
+ prompts interactively (never as a command argument, so it never lands in shell history) and saves
101
+ it to `~/.securevibe/config.json`, used automatically whenever the env var isn't already set.
102
+ - **A second AI feature, `securevibe explain`:** for critical/high findings, asks
103
+ the same configured provider for a plain-language explanation grounded in the
104
+ actual flagged code (not just the generic why/fix text every finding of that
105
+ detector shares). Capped at the top 10 qualifying findings per run. Falls back to
106
+ the static why/fix text with no key configured, or if a call fails — an
107
+ explanation is always additional context, never required to use the command.
108
+ `fix --apply` with no key configured still offers to set one up as a fallback.
109
+ `securevibe config show` lists what's stored (masked).
86
110
 
87
111
  **Honesty (read this):**
88
112
  - A "fixed" finding means *our detector no longer flags it* — not that the app is proven secure.
@@ -111,8 +135,9 @@ against a local copy of the [OSV](https://osv.dev) advisory database, and report
111
135
  CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
112
136
  version while preserving your range prefix (`^`, `~`, `==`, ...).
113
137
  - **Offline first.** A bundled seed (`data/osv-seed.json`) ships with the CLI, so `deps` works with
114
- no network. `db update` is the **only networked command**: it streams the public OSV npm and PyPI
115
- dumps into `~/.securevibe/db`. Nothing about your code or package list leaves the machine.
138
+ no network. `db update` is the only command that **downloads** anything (the public OSV npm and
139
+ PyPI dumps, into `~/.securevibe/db`). `pr-comment` is the only command that **uploads** anything,
140
+ and only a findings summary already computed locally — never your code or package list.
116
141
  - **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
117
142
  it means nothing matched the local database on that date, which is not a proof of safety.
118
143
 
@@ -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
  });
@@ -34,11 +34,15 @@ export function gitDir(root) {
34
34
  /**
35
35
  * Staged files (added/copied/modified) as absolute paths, filtered to those
36
36
  * that exist on disk. Returns null if not a git repo / git unavailable.
37
+ * `--relative` matters when `root` is a subdirectory of the repo: git diff
38
+ * paths are toplevel-relative by default, which would silently mis-resolve
39
+ * against `root` otherwise (and --relative also scopes the diff to `root`,
40
+ * matching "scan this path" semantics for a subdirectory target).
37
41
  */
38
42
  export async function listStagedFiles(root) {
39
43
  if (!isGitRepo(root))
40
44
  return null;
41
- const out = git(root, ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"]);
45
+ const out = git(root, ["diff", "--cached", "--relative", "--name-only", "--diff-filter=ACM", "-z"]);
42
46
  if (out == null)
43
47
  return null;
44
48
  const rels = out.split("\0").filter((s) => s.length > 0);
@@ -56,3 +60,32 @@ export async function listStagedFiles(root) {
56
60
  }
57
61
  return abs;
58
62
  }
63
+ /**
64
+ * Files changed (added/copied/modified) between `baseRef` and the working
65
+ * tree, as absolute paths, filtered to those that still exist on disk. Used
66
+ * by `--diff` (PR-scoped scans) — same shape as listStagedFiles, but diffing
67
+ * against an arbitrary ref instead of the index. Returns null if not a git
68
+ * repo / git unavailable, or if the diff itself fails (e.g. `baseRef` doesn't
69
+ * exist in this checkout).
70
+ */
71
+ export async function listChangedFiles(root, baseRef) {
72
+ if (!isGitRepo(root))
73
+ return null;
74
+ const out = git(root, ["diff", "--relative", "--name-only", "--diff-filter=ACM", "-z", baseRef]);
75
+ if (out == null)
76
+ return null;
77
+ const rels = out.split("\0").filter((s) => s.length > 0);
78
+ const abs = [];
79
+ for (const rel of rels) {
80
+ const p = path.join(root, rel);
81
+ try {
82
+ const st = await fs.stat(p);
83
+ if (st.isFile())
84
+ abs.push(p);
85
+ }
86
+ catch {
87
+ /* changed-but-removed or unreadable — skip */
88
+ }
89
+ }
90
+ return abs;
91
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal GitHub REST client for the pr-comment command (doc 12). Posts or
3
+ * updates a single PR comment, found via a hidden marker so re-runs replace
4
+ * it in place instead of piling up new comments. Every failure (auth,
5
+ * network, rate limit) is caught and returned as a result — this must never
6
+ * throw, since posting the comment is best-effort visibility, not a gate
7
+ * (the existing `scan --ci` step is what's allowed to fail the build).
8
+ */
9
+ export const PR_COMMENT_MARKER = "<!-- securevibe-pr-comment -->";
10
+ const API_BASE = "https://api.github.com";
11
+ function authHeaders(token) {
12
+ return {
13
+ Authorization: `Bearer ${token}`,
14
+ Accept: "application/vnd.github+json",
15
+ "Content-Type": "application/json",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ };
18
+ }
19
+ /** Finds an existing SecureVibe comment on the PR by its hidden marker, or null if none/on any failure. */
20
+ async function findExistingComment(target) {
21
+ try {
22
+ const res = await fetch(`${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments?per_page=100`, { headers: authHeaders(target.token) });
23
+ if (!res.ok)
24
+ return null;
25
+ const comments = await res.json();
26
+ if (!Array.isArray(comments))
27
+ return null;
28
+ const existing = comments.find((c) => typeof c?.body === "string" && c.body.includes(PR_COMMENT_MARKER));
29
+ return existing?.id ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Posts a new PR comment, or updates the existing SecureVibe comment (found
37
+ * via the marker) in place. Never throws — any failure comes back as
38
+ * `{ posted: false, error }` so the caller can warn and continue.
39
+ */
40
+ export async function postOrUpdateComment(target, body) {
41
+ try {
42
+ const existingId = await findExistingComment(target);
43
+ const url = existingId
44
+ ? `${API_BASE}/repos/${target.owner}/${target.repo}/issues/comments/${existingId}`
45
+ : `${API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.prNumber}/comments`;
46
+ const res = await fetch(url, {
47
+ method: existingId ? "PATCH" : "POST",
48
+ headers: authHeaders(target.token),
49
+ body: JSON.stringify({ body }),
50
+ });
51
+ if (!res.ok) {
52
+ return { posted: false, updated: false, error: `GitHub API error: ${res.status}` };
53
+ }
54
+ return { posted: true, updated: existingId !== null };
55
+ }
56
+ catch (err) {
57
+ return { posted: false, updated: false, error: err?.message ?? String(err) };
58
+ }
59
+ }
@@ -2,13 +2,50 @@
2
2
  * `securevibe init` (doc 12) — make SecureVibe always-on in a project with one
3
3
  * command. Installs a git pre-commit guard (blocks commits that introduce a
4
4
  * BLOCK-level issue, including committed secrets), a GitHub Actions workflow
5
- * (PR scan + SARIF upload to code scanning), gitignores `.env` and backups, and
5
+ * (PR scan + SARIF upload + PR summary comment), gitignores `.env` and backups, and
6
6
  * drops a minimal config. Idempotent: existing files are left alone unless
7
7
  * `--force`. Everything runs locally — no account, no upload, no API key.
8
8
  */
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",
@@ -35,8 +72,8 @@ if [ "$?" -eq 2 ]; then
35
72
  fi
36
73
  exit 0
37
74
  `;
38
- const WORKFLOW = `# SecureVibe — scan every PR and upload findings to GitHub code scanning.
39
- # Managed by \`securevibe init\`.
75
+ const WORKFLOW = `# SecureVibe — scan every PR, upload findings to GitHub code scanning, and
76
+ # post a PR summary comment. Managed by \`securevibe init\`.
40
77
  name: SecureVibe
41
78
  on:
42
79
  pull_request:
@@ -45,11 +82,14 @@ on:
45
82
  permissions:
46
83
  contents: read
47
84
  security-events: write
85
+ issues: write
48
86
  jobs:
49
87
  securevibe:
50
88
  runs-on: ubuntu-latest
51
89
  steps:
52
90
  - uses: actions/checkout@v4
91
+ with:
92
+ fetch-depth: 0
53
93
  - uses: actions/setup-node@v4
54
94
  with:
55
95
  node-version: 20
@@ -64,6 +104,12 @@ jobs:
64
104
  sarif_file: securevibe.sarif
65
105
  - name: Gate the build on deployment readiness
66
106
  run: securevibe scan . --ci
107
+ - name: PR comment
108
+ if: always() && github.event_name == 'pull_request'
109
+ env:
110
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
111
+ PR_NUMBER: \${{ github.event.pull_request.number }}
112
+ run: securevibe pr-comment . --diff \${{ github.event.pull_request.base.sha }}
67
113
  `;
68
114
  export async function runInit(root, opts = {}) {
69
115
  const absRoot = path.resolve(root);
@@ -85,7 +131,7 @@ export async function runInit(root, opts = {}) {
85
131
  });
86
132
  }
87
133
  // 4) GitHub Actions workflow.
88
- actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload"));
134
+ actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload + PR summary comment"));
89
135
  return { root: absRoot, isGitRepo: repo, actions };
90
136
  }
91
137
  async function ensureGitignore(root) {
@@ -1,6 +1,13 @@
1
1
  import { walk } from "./ast.js";
2
- /** Untrusted-input sources. Matching text is considered tainted at its origin. */
3
- const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bawait\s+\w+\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
2
+ /**
3
+ * Untrusted-input sources. Matching text is considered tainted at its origin.
4
+ * `req(uest)?.json()` (with or without a preceding `await`) is intentionally
5
+ * scoped to those two names, not `\w+.json()` generically -- an unrestricted
6
+ * `\w+` also matched `await res.json()` on a response *we* fetched (e.g. an
7
+ * outbound GitHub API call), wrongly treating our own already-fetched data
8
+ * as untrusted input.
9
+ */
10
+ const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
4
11
  function collectAssignments(root) {
5
12
  const out = [];
6
13
  walk(root, (n) => {
@@ -32,7 +39,12 @@ function referencesTainted(text, tainted) {
32
39
  if (SOURCE_RE.test(text))
33
40
  return true;
34
41
  for (const v of tainted) {
35
- // word-boundary match so `id` doesn't match `idx`
42
+ // word-boundary match so `id` doesn't match `idx` -- but this also can't
43
+ // tell a real reference to `v` from `v`'s name coincidentally appearing
44
+ // inside a string/template literal (e.g. a URL path segment: a tainted
45
+ // var named `comments` false-matched a fetch URL literally containing
46
+ // "/comments"). Known false-positive class, not fixed here -- narrowing
47
+ // SOURCE_RE only closes the one source that produced this instance.
36
48
  const re = new RegExp(`\\b${escapeRe(v)}\\b`);
37
49
  if (re.test(text))
38
50
  return true;
@@ -2,6 +2,19 @@
2
2
  * Shared helpers used by both the one-shot CLI commands (index.ts) and the
3
3
  * interactive REPL (repl.ts), so the two entry points never duplicate logic.
4
4
  */
5
+ /**
6
+ * Pure (no I/O) check: is there no AI-fixer provider key configured right
7
+ * now? Returns the static nudge text to print when true, undefined when a
8
+ * key is already set. Shared by the interactive `askLine`-based offer below
9
+ * and by the Ink REPL path (ui/repl-tui/App.ts), which can't safely open a
10
+ * second readline on process.stdin while Ink holds it in raw mode — so it
11
+ * prints this instead of calling maybeOfferAiFixerSetup().
12
+ */
13
+ export function noKeyNudgeMessage() {
14
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
15
+ return undefined;
16
+ return " no AI-fixer key set — those findings show as manual review. Run `securevibe config set-key groq` (outside the REPL) to enable AI-powered fixes.\n\n";
17
+ }
5
18
  /**
6
19
  * If `fix` is about to run with no AI-fixer provider key available (and we're
7
20
  * in an interactive terminal), offer to set one up on the spot — a free Groq
@@ -9,7 +22,7 @@
9
22
  * never touches process.env if the user declines or a key is already set.
10
23
  */
11
24
  export async function maybeOfferAiFixerSetup() {
12
- if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
25
+ if (!noKeyNudgeMessage())
13
26
  return;
14
27
  const { isInteractive, askLine } = await import("./ui/prompt.js");
15
28
  if (!isInteractive())
@@ -30,3 +43,33 @@ export async function maybeOfferAiFixerSetup() {
30
43
  process.env.GROQ_API_KEY = key;
31
44
  process.stderr.write(` Saved to ${configFilePath()}. This run will use it.\n\n`);
32
45
  }
46
+ /**
47
+ * Runs once at the end of `init`, interactive-only. Users were learning a
48
+ * provider key was needed only mid-`fix` — too late, past the point of
49
+ * expecting the fixer to already work. `init` already runs once per project
50
+ * before any `fix`, so it's the right place to surface this early — but only
51
+ * for repos that actually use an LLM SDK, and only once (a decline is
52
+ * remembered per-project so we don't nag).
53
+ */
54
+ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
55
+ const { applyStoredKeysToEnv } = await import("./config.js");
56
+ await applyStoredKeysToEnv(deps?.configFile);
57
+ if (process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)
58
+ return;
59
+ const { readProjectConfig, patchProjectConfig, looksAiRelated } = await import("./engine/init.js");
60
+ const cfg = await readProjectConfig(root);
61
+ if (cfg.aiFixerPromptDismissed)
62
+ return;
63
+ if (!(await looksAiRelated(root)))
64
+ return;
65
+ const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
66
+ if (!isInteractive())
67
+ return;
68
+ const writePreamble = deps?.writePreamble ?? ((text) => process.stderr.write(text));
69
+ writePreamble("\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");
70
+ const offer = deps?.offer ?? maybeOfferAiFixerSetup;
71
+ await offer();
72
+ if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
73
+ await patchProjectConfig(root, { aiFixerPromptDismissed: true });
74
+ }
75
+ }