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 +37 -12
- package/dist/engine/explain.js +79 -0
- package/dist/engine/fix/llm.js +21 -9
- package/dist/engine/git.js +34 -1
- package/dist/engine/github.js +59 -0
- package/dist/engine/init.js +50 -4
- package/dist/engine/taint.js +15 -3
- package/dist/index-support.js +44 -1
- package/dist/index.js +83 -5
- package/dist/repl.js +122 -51
- package/dist/ui/explain.js +72 -0
- package/dist/ui/prComment.js +55 -0
- package/dist/ui/repl-tui/App.js +130 -0
- package/dist/ui/repl-tui/InputBox.js +48 -0
- package/dist/ui/repl-tui/SlashMenu.js +10 -0
- package/dist/ui/repl-tui/Spinner.js +18 -0
- package/dist/ui/repl-tui/StatusLine.js +15 -0
- package/dist/ui/repl-tui/commandRegistry.js +36 -0
- package/dist/ui/repl-tui/outputStream.js +16 -0
- package/dist/version.js +1 -1
- package/package.json +71 -66
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
|
|
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,
|
|
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`)
|
|
59
|
-
**no account,
|
|
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:**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
115
|
-
dumps into `~/.securevibe/db
|
|
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
|
+
}
|
package/dist/engine/fix/llm.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
117
|
+
return null;
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
|
-
async function callAnthropic(
|
|
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
|
|
121
|
-
messages: [{ role: "user", content:
|
|
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(
|
|
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:
|
|
138
|
-
{ role: "user", content:
|
|
149
|
+
{ role: "system", content: system },
|
|
150
|
+
{ role: "user", content: user },
|
|
139
151
|
],
|
|
140
152
|
}),
|
|
141
153
|
});
|
package/dist/engine/git.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/engine/init.js
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
package/dist/engine/taint.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { walk } from "./ast.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
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;
|
package/dist/index-support.js
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|