securevibe 0.1.10 → 0.1.13
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 +29 -7
- package/dist/engine/chat.js +76 -0
- package/dist/engine/fix/llm.js +15 -11
- package/dist/engine/git.js +34 -1
- package/dist/engine/github.js +59 -0
- package/dist/engine/init.js +13 -4
- package/dist/engine/taint.js +15 -3
- package/dist/index-support.js +16 -2
- package/dist/index.js +51 -5
- package/dist/repl.js +203 -66
- package/dist/ui/fix.js +1 -1
- package/dist/ui/prComment.js +55 -0
- package/dist/ui/prompt.js +6 -3
- package/dist/ui/repl-tui/App.js +132 -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 +38 -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
|
@@ -28,8 +28,9 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
|
|
|
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
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) |
|
|
31
32
|
| `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
|
|
32
|
-
| `securevibe db <update\|status>` | Manage the local OSV database — `update` is
|
|
33
|
+
| `securevibe db <update\|status>` | Manage the local OSV database — `update` is networked |
|
|
33
34
|
| `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
|
|
34
35
|
| `securevibe ai-audit [path]` | Focus on the AI-agent attack surface (doc 04) |
|
|
35
36
|
| `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
|
|
@@ -40,11 +41,26 @@ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE
|
|
|
40
41
|
|
|
41
42
|
Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
|
|
42
43
|
`--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
|
|
43
|
-
`--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`).
|
|
44
47
|
`fix` flags: `--apply` (write to disk; asks before each change), `--yes` (apply every
|
|
45
48
|
verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-only), `--json`.
|
|
46
49
|
`init` flags: `--force` (replace an existing hook/workflow/config).
|
|
47
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
|
+
|
|
57
|
+
Inside the REPL, anything you type that is not a command is a question for the AI about
|
|
58
|
+
your last scan: "why is finding 3 critical?", "which of these should I fix first?".
|
|
59
|
+
Answers are grounded in the scan results only; the assistant never sees your files and
|
|
60
|
+
never claims your code is secure. The first question each session asks before anything
|
|
61
|
+
is sent to your provider. Needs the same key as `fix` and `explain`
|
|
62
|
+
(`securevibe config set-key groq`).
|
|
63
|
+
|
|
48
64
|
## Always-on (doc 12)
|
|
49
65
|
|
|
50
66
|
`securevibe init` makes the tool continuous so insecure code can't slip through:
|
|
@@ -53,11 +69,16 @@ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-on
|
|
|
53
69
|
a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
|
|
54
70
|
clean version through);
|
|
55
71
|
- a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
|
|
56
|
-
SARIF to GitHub code scanning,
|
|
72
|
+
SARIF to GitHub code scanning, gates the build on deployment-readiness, and posts a PR comment
|
|
73
|
+
summarizing findings in the *changed* files only (updated in place on new commits, not reposted —
|
|
74
|
+
and the only other command besides `db update` that talks to the network, and only to post a
|
|
75
|
+
findings summary already computed locally, never your source code);
|
|
57
76
|
- gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
|
|
58
77
|
|
|
59
|
-
It is idempotent (existing files are left alone unless `--force`)
|
|
60
|
-
**no account,
|
|
78
|
+
It is idempotent (existing files are left alone unless `--force`). Everything it installs runs
|
|
79
|
+
locally — **no account, and your secrets / API keys are never stored or transmitted.** The one
|
|
80
|
+
exception is the PR-comment step above, which uploads a findings summary (never your source code)
|
|
81
|
+
already computed by the local scan.
|
|
61
82
|
|
|
62
83
|
## The fix loop (doc 05)
|
|
63
84
|
|
|
@@ -121,8 +142,9 @@ against a local copy of the [OSV](https://osv.dev) advisory database, and report
|
|
|
121
142
|
CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
|
|
122
143
|
version while preserving your range prefix (`^`, `~`, `==`, ...).
|
|
123
144
|
- **Offline first.** A bundled seed (`data/osv-seed.json`) ships with the CLI, so `deps` works with
|
|
124
|
-
no network. `db update` is the
|
|
125
|
-
dumps into `~/.securevibe/db
|
|
145
|
+
no network. `db update` is the only command that **downloads** anything (the public OSV npm and
|
|
146
|
+
PyPI dumps, into `~/.securevibe/db`). `pr-comment` is the only command that **uploads** anything,
|
|
147
|
+
and only a findings summary already computed locally — never your code or package list.
|
|
126
148
|
- **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
|
|
127
149
|
it means nothing matched the local database on that date, which is not a proof of safety.
|
|
128
150
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from "./types.js";
|
|
2
|
+
import { llmChatMessages } from "./fix/llm.js";
|
|
3
|
+
/** Findings included in the grounding table, top N by severity then confidence. */
|
|
4
|
+
export const CHAT_MAX_FINDINGS = 30;
|
|
5
|
+
/** Prior exchanges (user + assistant pairs) kept and re-sent per question. */
|
|
6
|
+
export const CHAT_MAX_EXCHANGES = 10;
|
|
7
|
+
const CHAT_SYSTEM = [
|
|
8
|
+
"You are the assistant inside SecureVibe, a security scanner CLI. The user just scanned their repository; the scan results are below. Answer their questions about those results.",
|
|
9
|
+
"Rules you must never break:",
|
|
10
|
+
"- Answer ONLY from the scan results provided. You cannot see the repository's files, only the findings listed. If asked about anything not in them, say you can only see the scan results.",
|
|
11
|
+
"- Never call the code \"secure\", \"safe\", or \"unhackable\". A clean scan means our detectors found nothing, not that the code is secure.",
|
|
12
|
+
"- When you give remediation advice, end with: review the diff and run your tests.",
|
|
13
|
+
"- The user can run these commands: scan, fix (--apply), explain, ai-audit, protect, attack-map, score, deps, ready. Recommend those when relevant. You cannot fix, read files, or run anything yourself; never claim otherwise.",
|
|
14
|
+
"- Findings are numbered; \"finding 3\" means number 3 in the list below.",
|
|
15
|
+
"- Keep answers short and concrete: plain prose, no markdown headings, no bullet spam.",
|
|
16
|
+
].join("\n");
|
|
17
|
+
/** The compact, numbered findings table the assistant is grounded in. */
|
|
18
|
+
export function buildGrounding(result) {
|
|
19
|
+
const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
|
|
20
|
+
const shown = sorted.slice(0, CHAT_MAX_FINDINGS);
|
|
21
|
+
const lines = [
|
|
22
|
+
`Target: ${result.root}`,
|
|
23
|
+
`Score: grade ${result.score.grade} (composite ${result.score.composite}), readiness: ${result.score.readiness}`,
|
|
24
|
+
];
|
|
25
|
+
if (result.launchReadiness) {
|
|
26
|
+
lines.push(`Launch verdict: ${result.launchReadiness.verdict} (${result.launchReadiness.blockers} blockers, ${result.launchReadiness.warnings} warnings)`);
|
|
27
|
+
}
|
|
28
|
+
if (shown.length === 0) {
|
|
29
|
+
lines.push("Findings: none (no findings — our detectors found nothing; that does not prove the code is secure).");
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
|
32
|
+
lines.push(shown.length < sorted.length
|
|
33
|
+
? `Findings (top ${shown.length} of ${sorted.length}, by severity):`
|
|
34
|
+
: `Findings (${shown.length}):`);
|
|
35
|
+
shown.forEach((f, i) => {
|
|
36
|
+
lines.push(`${i + 1}. [${f.severity}] ${f.detector} ${f.file}:${f.line} - ${f.title}`);
|
|
37
|
+
if (f.evidence)
|
|
38
|
+
lines.push(` evidence: ${f.evidence.trim()}`);
|
|
39
|
+
});
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
|
42
|
+
export class ChatSession {
|
|
43
|
+
callLlm;
|
|
44
|
+
history = [];
|
|
45
|
+
grounding = null;
|
|
46
|
+
constructor(callLlm = llmChatMessages) {
|
|
47
|
+
this.callLlm = callLlm;
|
|
48
|
+
}
|
|
49
|
+
/** New grounding means a fresh conversation: the old answers described old results. */
|
|
50
|
+
setScanResult(result) {
|
|
51
|
+
this.grounding = buildGrounding(result);
|
|
52
|
+
this.history = [];
|
|
53
|
+
}
|
|
54
|
+
reset() {
|
|
55
|
+
this.grounding = null;
|
|
56
|
+
this.history = [];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ask one question. Returns null (and leaves history untouched) when there
|
|
60
|
+
* is no grounding or the call fails — the caller owns the user-facing
|
|
61
|
+
* degradation message, same contract as llmChat.
|
|
62
|
+
*/
|
|
63
|
+
async ask(question) {
|
|
64
|
+
if (this.grounding === null)
|
|
65
|
+
return null;
|
|
66
|
+
const messages = [...this.history, { role: "user", content: question }];
|
|
67
|
+
const answer = await this.callLlm(`${CHAT_SYSTEM}\n\nScan results:\n${this.grounding}`, messages);
|
|
68
|
+
if (answer == null)
|
|
69
|
+
return null;
|
|
70
|
+
this.history.push({ role: "user", content: question }, { role: "assistant", content: answer });
|
|
71
|
+
const max = CHAT_MAX_EXCHANGES * 2;
|
|
72
|
+
if (this.history.length > max)
|
|
73
|
+
this.history = this.history.slice(-max);
|
|
74
|
+
return answer;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/engine/fix/llm.js
CHANGED
|
@@ -102,22 +102,29 @@ export async function llmFix(input) {
|
|
|
102
102
|
/**
|
|
103
103
|
* Generic single-turn chat call against whichever provider is configured.
|
|
104
104
|
* Shared by llmFix (full-file rewrites) and engine/explain.ts (plain-language
|
|
105
|
-
* finding explanations)
|
|
106
|
-
*
|
|
107
|
-
* gracefully and must never throw because of this.
|
|
105
|
+
* finding explanations). Returns null on no-provider / network / auth / quota
|
|
106
|
+
* failure — callers degrade gracefully and must never throw because of this.
|
|
108
107
|
*/
|
|
109
108
|
export async function llmChat(system, user) {
|
|
109
|
+
return llmChatMessages(system, [{ role: "user", content: user }]);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Multi turn variant: same provider selection and same null-on-failure
|
|
113
|
+
* contract as llmChat, but takes the full message history. Used by
|
|
114
|
+
* engine/chat.ts (the REPL's free text chat).
|
|
115
|
+
*/
|
|
116
|
+
export async function llmChatMessages(system, messages) {
|
|
110
117
|
const provider = selectProvider();
|
|
111
118
|
if (!provider)
|
|
112
119
|
return null;
|
|
113
120
|
try {
|
|
114
|
-
return provider === "groq" ? await callGroq(system,
|
|
121
|
+
return provider === "groq" ? await callGroq(system, messages) : await callAnthropic(system, messages);
|
|
115
122
|
}
|
|
116
123
|
catch {
|
|
117
124
|
return null;
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
|
-
async function callAnthropic(system,
|
|
127
|
+
async function callAnthropic(system, messages) {
|
|
121
128
|
let Anthropic;
|
|
122
129
|
try {
|
|
123
130
|
({ default: Anthropic } = await import("@anthropic-ai/sdk"));
|
|
@@ -130,12 +137,12 @@ async function callAnthropic(system, user) {
|
|
|
130
137
|
model: modelFor("anthropic"),
|
|
131
138
|
max_tokens: 8192,
|
|
132
139
|
system,
|
|
133
|
-
messages
|
|
140
|
+
messages,
|
|
134
141
|
});
|
|
135
142
|
return extractAnthropicText(msg);
|
|
136
143
|
}
|
|
137
144
|
/** Groq's chat-completions endpoint is OpenAI-compatible — plain fetch, no SDK. */
|
|
138
|
-
async function callGroq(system,
|
|
145
|
+
async function callGroq(system, messages) {
|
|
139
146
|
const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
|
|
140
147
|
method: "POST",
|
|
141
148
|
headers: {
|
|
@@ -145,10 +152,7 @@ async function callGroq(system, user) {
|
|
|
145
152
|
body: JSON.stringify({
|
|
146
153
|
model: modelFor("groq"),
|
|
147
154
|
max_tokens: 8192,
|
|
148
|
-
messages: [
|
|
149
|
-
{ role: "system", content: system },
|
|
150
|
-
{ role: "user", content: user },
|
|
151
|
-
],
|
|
155
|
+
messages: [{ role: "system", content: system }, ...messages],
|
|
152
156
|
}),
|
|
153
157
|
});
|
|
154
158
|
if (!res.ok)
|
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,7 +2,7 @@
|
|
|
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
|
*/
|
|
@@ -72,8 +72,8 @@ if [ "$?" -eq 2 ]; then
|
|
|
72
72
|
fi
|
|
73
73
|
exit 0
|
|
74
74
|
`;
|
|
75
|
-
const WORKFLOW = `# SecureVibe — scan every PR
|
|
76
|
-
# 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\`.
|
|
77
77
|
name: SecureVibe
|
|
78
78
|
on:
|
|
79
79
|
pull_request:
|
|
@@ -82,11 +82,14 @@ on:
|
|
|
82
82
|
permissions:
|
|
83
83
|
contents: read
|
|
84
84
|
security-events: write
|
|
85
|
+
issues: write
|
|
85
86
|
jobs:
|
|
86
87
|
securevibe:
|
|
87
88
|
runs-on: ubuntu-latest
|
|
88
89
|
steps:
|
|
89
90
|
- uses: actions/checkout@v4
|
|
91
|
+
with:
|
|
92
|
+
fetch-depth: 0
|
|
90
93
|
- uses: actions/setup-node@v4
|
|
91
94
|
with:
|
|
92
95
|
node-version: 20
|
|
@@ -101,6 +104,12 @@ jobs:
|
|
|
101
104
|
sarif_file: securevibe.sarif
|
|
102
105
|
- name: Gate the build on deployment readiness
|
|
103
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 }}
|
|
104
113
|
`;
|
|
105
114
|
export async function runInit(root, opts = {}) {
|
|
106
115
|
const absRoot = path.resolve(root);
|
|
@@ -122,7 +131,7 @@ export async function runInit(root, opts = {}) {
|
|
|
122
131
|
});
|
|
123
132
|
}
|
|
124
133
|
// 4) GitHub Actions workflow.
|
|
125
|
-
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"));
|
|
126
135
|
return { root: absRoot, isGitRepo: repo, actions };
|
|
127
136
|
}
|
|
128
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())
|
|
@@ -52,7 +65,8 @@ export async function maybeOfferAiFixerSetupDuringInit(root, deps) {
|
|
|
52
65
|
const isInteractive = deps?.isInteractive ?? (await import("./ui/prompt.js")).isInteractive;
|
|
53
66
|
if (!isInteractive())
|
|
54
67
|
return;
|
|
55
|
-
|
|
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");
|
|
56
70
|
const offer = deps?.offer ?? maybeOfferAiFixerSetup;
|
|
57
71
|
await offer();
|
|
58
72
|
if (!(process.env.GROQ_API_KEY || process.env.ANTHROPIC_API_KEY)) {
|
package/dist/index.js
CHANGED
|
@@ -22,16 +22,18 @@ function addCommon(cmd) {
|
|
|
22
22
|
.option("--sarif", "output SARIF 2.1.0 (for CI / GitHub code scanning)")
|
|
23
23
|
.option("--no-color", "disable coloured output")
|
|
24
24
|
.option("--staged", "scan only git-staged files (for pre-commit guards)")
|
|
25
|
+
.option("--diff <base>", "scan only files changed since <base> (for PR checks)")
|
|
25
26
|
.option("--ci", "CI mode: exit non-zero based on deployment readiness");
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* Free beta metering: one use per analysis command against the daily quota.
|
|
29
|
-
* The --staged commit guard
|
|
30
|
-
*
|
|
31
|
-
* wasn't
|
|
30
|
+
* The --staged commit guard and --diff (PR-scoped CI scans) are both exempt —
|
|
31
|
+
* an always-on automated guard must never be the thing that breaks. Returns
|
|
32
|
+
* the usage state so callers can show it, or undefined when this run wasn't
|
|
33
|
+
* metered.
|
|
32
34
|
*/
|
|
33
35
|
async function meter(opts = {}) {
|
|
34
|
-
if (opts.staged)
|
|
36
|
+
if (opts.staged || opts.diff)
|
|
35
37
|
return undefined;
|
|
36
38
|
const { recordUse, limitMessage } = await import("./usage.js");
|
|
37
39
|
const r = await recordUse();
|
|
@@ -62,6 +64,20 @@ async function runScan(pathArg, opts, scanOpts = {}) {
|
|
|
62
64
|
scanOpts = { ...scanOpts, files: staged };
|
|
63
65
|
}
|
|
64
66
|
}
|
|
67
|
+
// --diff <base>: analyze only files changed since <base> (PR-scoped CI checks).
|
|
68
|
+
if (opts.diff) {
|
|
69
|
+
const path = await import("node:path");
|
|
70
|
+
const { listChangedFiles } = await import("./engine/git.js");
|
|
71
|
+
const absRoot = path.resolve(target);
|
|
72
|
+
const changed = await listChangedFiles(absRoot, opts.diff);
|
|
73
|
+
if (changed === null) {
|
|
74
|
+
if (!quiet)
|
|
75
|
+
process.stderr.write(` · could not diff against ${opts.diff} — scanning the whole tree\n`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
scanOpts = { ...scanOpts, files: changed };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
65
81
|
const result = await scan(target, {
|
|
66
82
|
...scanOpts,
|
|
67
83
|
onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
|
|
@@ -201,6 +217,36 @@ program
|
|
|
201
217
|
}
|
|
202
218
|
await maybeNotifyUpdate({ json: opts.json });
|
|
203
219
|
});
|
|
220
|
+
program
|
|
221
|
+
.command("pr-comment")
|
|
222
|
+
.description("Post/update a PR summary of changed-file findings (used by the GitHub Actions workflow)")
|
|
223
|
+
.argument("[path]", "path to the repository", ".")
|
|
224
|
+
.option("--diff <base>", "base ref to diff against (the workflow passes the PR's base SHA)")
|
|
225
|
+
.action(async (pathArg, opts) => {
|
|
226
|
+
if (!opts.diff) {
|
|
227
|
+
process.stderr.write(" pr-comment: --diff <base> is required (the workflow passes the PR's base SHA).\n");
|
|
228
|
+
process.exit(64);
|
|
229
|
+
}
|
|
230
|
+
const { result } = await runScan(pathArg, { diff: opts.diff });
|
|
231
|
+
const { renderPrComment } = await import("./ui/prComment.js");
|
|
232
|
+
const body = renderPrComment(result);
|
|
233
|
+
const token = process.env.GITHUB_TOKEN;
|
|
234
|
+
const repoSlug = process.env.GITHUB_REPOSITORY;
|
|
235
|
+
const prNumber = process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : undefined;
|
|
236
|
+
if (!token || !repoSlug || !prNumber) {
|
|
237
|
+
process.stdout.write(body + "\n");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const [owner, repo] = repoSlug.split("/");
|
|
241
|
+
const { postOrUpdateComment } = await import("./engine/github.js");
|
|
242
|
+
const posted = await postOrUpdateComment({ owner, repo, prNumber, token }, body);
|
|
243
|
+
if (!posted.posted) {
|
|
244
|
+
process.stderr.write(` pr-comment: could not post to GitHub (${posted.error ?? "unknown error"}) — printing instead.\n`);
|
|
245
|
+
process.stdout.write(body + "\n");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
process.stderr.write(` pr-comment: ${posted.updated ? "updated" : "posted"} the summary on PR #${prNumber}.\n`);
|
|
249
|
+
});
|
|
204
250
|
addCommon(program
|
|
205
251
|
.command("ai-audit")
|
|
206
252
|
.description("Focus on the AI-application / agent attack surface (doc 04)")
|
|
@@ -275,7 +321,7 @@ addCommon(program
|
|
|
275
321
|
});
|
|
276
322
|
program
|
|
277
323
|
.command("db")
|
|
278
|
-
.description("Manage the local OSV vulnerability database (
|
|
324
|
+
.description("Manage the local OSV vulnerability database (update is networked)")
|
|
279
325
|
.argument("<action>", "update | status")
|
|
280
326
|
.option("--no-color", "disable coloured output")
|
|
281
327
|
.action(async (action, opts) => {
|