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/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`),
|
|
@@ -128,6 +144,8 @@ program
|
|
|
128
144
|
const { renderInit } = await import("./ui/init.js");
|
|
129
145
|
const result = await runInit(pathArg ?? ".", { force: opts.force });
|
|
130
146
|
process.stdout.write(renderInit(result) + "\n");
|
|
147
|
+
const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
|
|
148
|
+
await maybeOfferAiFixerSetupDuringInit(pathArg ?? ".");
|
|
131
149
|
});
|
|
132
150
|
program
|
|
133
151
|
.command("fix")
|
|
@@ -169,6 +187,66 @@ program
|
|
|
169
187
|
}
|
|
170
188
|
await maybeNotifyUpdate({ json: opts.json });
|
|
171
189
|
});
|
|
190
|
+
program
|
|
191
|
+
.command("explain")
|
|
192
|
+
.description("AI-powered plain-language explanations for critical/high findings")
|
|
193
|
+
.argument("[path]", "path to the repository", ".")
|
|
194
|
+
.option("--json", "output machine-readable JSON")
|
|
195
|
+
.option("--no-color", "disable coloured output")
|
|
196
|
+
.action(async (pathArg, opts) => {
|
|
197
|
+
if (opts.color === false)
|
|
198
|
+
process.env.NO_COLOR = "1";
|
|
199
|
+
const { applyStoredKeysToEnv } = await import("./config.js");
|
|
200
|
+
await applyStoredKeysToEnv();
|
|
201
|
+
if (!opts.json) {
|
|
202
|
+
const { maybeOfferAiFixerSetup } = await import("./index-support.js");
|
|
203
|
+
await maybeOfferAiFixerSetup();
|
|
204
|
+
}
|
|
205
|
+
const { result, usage } = await runScan(pathArg, { json: opts.json, color: opts.color });
|
|
206
|
+
const { explainFindings } = await import("./engine/explain.js");
|
|
207
|
+
const report = await explainFindings(result, {
|
|
208
|
+
onProgress: opts.json ? undefined : (m) => process.stderr.write(` · ${m}\n`),
|
|
209
|
+
});
|
|
210
|
+
if (opts.json) {
|
|
211
|
+
const { explainToJson } = await import("./ui/explain.js");
|
|
212
|
+
process.stdout.write(explainToJson(report) + "\n");
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const { renderExplain } = await import("./ui/explain.js");
|
|
216
|
+
process.stdout.write(renderExplain(report, usage) + "\n");
|
|
217
|
+
}
|
|
218
|
+
await maybeNotifyUpdate({ json: opts.json });
|
|
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
|
+
});
|
|
172
250
|
addCommon(program
|
|
173
251
|
.command("ai-audit")
|
|
174
252
|
.description("Focus on the AI-application / agent attack surface (doc 04)")
|
|
@@ -243,7 +321,7 @@ addCommon(program
|
|
|
243
321
|
});
|
|
244
322
|
program
|
|
245
323
|
.command("db")
|
|
246
|
-
.description("Manage the local OSV vulnerability database (
|
|
324
|
+
.description("Manage the local OSV vulnerability database (update is networked)")
|
|
247
325
|
.argument("<action>", "update | status")
|
|
248
326
|
.option("--no-color", "disable coloured output")
|
|
249
327
|
.action(async (action, opts) => {
|
package/dist/repl.js
CHANGED
|
@@ -40,27 +40,62 @@ async function meterOrPrint(io) {
|
|
|
40
40
|
}
|
|
41
41
|
return r;
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
" fix [--apply] [--yes] [--no-llm] [--json] fix findings",
|
|
47
|
-
" ai-audit | protect | attack-map | score | deps | ready [--json]",
|
|
48
|
-
" init [--force] wire up the pre-commit guard + CI",
|
|
49
|
-
" config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
|
|
50
|
-
" db update | status manage the local OSV database",
|
|
51
|
-
" cd <path> change the scan target",
|
|
52
|
-
" pwd show the current scan target",
|
|
53
|
-
" help this message",
|
|
54
|
-
" exit | quit leave the session",
|
|
55
|
-
].join("\n");
|
|
56
|
-
/** Public entry point: real stdio, refuses to start without a TTY. */
|
|
43
|
+
/** Public entry point: real stdio, refuses to start without a TTY. Mounts
|
|
44
|
+
* the Ink interactive app (ui/repl-tui/App.ts) -- runReplSession above stays
|
|
45
|
+
* the tested, TTY-free core; this is the real-terminal presentation layer. */
|
|
57
46
|
export async function runRepl(initialPath) {
|
|
58
47
|
if (!isInteractive()) {
|
|
59
48
|
process.stderr.write(" repl needs an interactive terminal.\n");
|
|
60
49
|
process.exitCode = 64;
|
|
61
50
|
return;
|
|
62
51
|
}
|
|
63
|
-
|
|
52
|
+
const { render } = await import("ink");
|
|
53
|
+
const React = (await import("react")).default;
|
|
54
|
+
const { App } = await import("./ui/repl-tui/App.js");
|
|
55
|
+
const { waitUntilExit } = render(React.createElement(App, { initialPath }));
|
|
56
|
+
await waitUntilExit();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Handle one line of input against the given state: built-in commands
|
|
60
|
+
* (exit/quit/q, help, pwd, cd) plus everything dispatch() handles. Returns
|
|
61
|
+
* true if the session should end after this line. Shared by the legacy
|
|
62
|
+
* readline loop (runReplSession, below) and the Ink app (ui/repl-tui/App.ts)
|
|
63
|
+
* — extracted so both drive the exact same behavior from one place.
|
|
64
|
+
*/
|
|
65
|
+
export async function processLine(line, state, io) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return false;
|
|
69
|
+
const tokens = tokenize(trimmed);
|
|
70
|
+
const cmd = tokens[0];
|
|
71
|
+
const rest = tokens.slice(1);
|
|
72
|
+
try {
|
|
73
|
+
if (cmd === "exit" || cmd === "quit" || cmd === "q")
|
|
74
|
+
return true;
|
|
75
|
+
if (cmd === "help" || cmd === "?") {
|
|
76
|
+
const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
|
|
77
|
+
write(io, renderHelp() + "\n\n");
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (cmd === "pwd") {
|
|
81
|
+
write(io, ` ${state.root}\n\n`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (cmd === "cd") {
|
|
85
|
+
if (!rest[0]) {
|
|
86
|
+
write(io, " usage: cd <path>\n\n");
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
state.root = path.resolve(state.root, rest[0]);
|
|
90
|
+
write(io, ` target: ${state.root}\n\n`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
await dispatch(cmd, rest, state, io);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
write(io, ` error: ${err?.message ?? err}\n\n`);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
64
99
|
}
|
|
65
100
|
/** The actual session loop, over injectable streams — testable without a TTY. */
|
|
66
101
|
export async function runReplSession(initialPath, io) {
|
|
@@ -72,39 +107,17 @@ export async function runReplSession(initialPath, io) {
|
|
|
72
107
|
write(io, ` target: ${state.root}\n\n`);
|
|
73
108
|
while (true) {
|
|
74
109
|
const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
continue;
|
|
78
|
-
const tokens = tokenize(line);
|
|
79
|
-
const cmd = tokens[0];
|
|
80
|
-
const rest = tokens.slice(1);
|
|
81
|
-
try {
|
|
82
|
-
if (cmd === "exit" || cmd === "quit" || cmd === "q")
|
|
83
|
-
break;
|
|
84
|
-
if (cmd === "help" || cmd === "?") {
|
|
85
|
-
write(io, HELP + "\n\n");
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (cmd === "pwd") {
|
|
89
|
-
write(io, ` ${state.root}\n\n`);
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (cmd === "cd") {
|
|
93
|
-
if (!rest[0]) {
|
|
94
|
-
write(io, " usage: cd <path>\n\n");
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
state.root = path.resolve(state.root, rest[0]);
|
|
98
|
-
write(io, ` target: ${state.root}\n\n`);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
await dispatch(cmd, rest, state, io);
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
104
|
-
write(io, ` error: ${err?.message ?? err}\n\n`);
|
|
105
|
-
}
|
|
110
|
+
if (await processLine(raw, state, io))
|
|
111
|
+
break;
|
|
106
112
|
}
|
|
107
113
|
}
|
|
114
|
+
/** Progress callback for scan()/runFix()/explainFindings(): prefers io.onProgress
|
|
115
|
+
* (Ink path) over the default scrolling-line behavior (legacy readline path). */
|
|
116
|
+
function progressCallback(io, asJson) {
|
|
117
|
+
if (asJson)
|
|
118
|
+
return undefined;
|
|
119
|
+
return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
|
|
120
|
+
}
|
|
108
121
|
async function dispatch(cmd, tokens, state, io) {
|
|
109
122
|
const asJson = hasFlag(tokens, "--json");
|
|
110
123
|
if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
|
|
@@ -112,7 +125,7 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
112
125
|
const usage = await meterOrPrint(io);
|
|
113
126
|
const result = await scan(state.root, {
|
|
114
127
|
aiOnly: cmd === "ai-audit",
|
|
115
|
-
onProgress:
|
|
128
|
+
onProgress: progressCallback(io, asJson),
|
|
116
129
|
});
|
|
117
130
|
state.lastResult = result;
|
|
118
131
|
if (asJson) {
|
|
@@ -129,15 +142,28 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
129
142
|
const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
|
|
130
143
|
const decision = decideApply(opts, true);
|
|
131
144
|
if (decision.apply && !hasFlag(tokens, "--no-llm")) {
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
if (io.inkMode) {
|
|
146
|
+
const { noKeyNudgeMessage } = await import("./index-support.js");
|
|
147
|
+
const msg = noKeyNudgeMessage();
|
|
148
|
+
if (msg)
|
|
149
|
+
write(io, msg);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const { maybeOfferAiFixerSetup } = await import("./index-support.js");
|
|
153
|
+
await maybeOfferAiFixerSetup();
|
|
154
|
+
}
|
|
134
155
|
}
|
|
135
156
|
const usage = await meterOrPrint(io);
|
|
157
|
+
const confirmFn = decision.prompt
|
|
158
|
+
? io.makeConfirm
|
|
159
|
+
? io.makeConfirm(renderApprovalRequest)
|
|
160
|
+
: makeFixConfirm(renderApprovalRequest, io.input, io.output)
|
|
161
|
+
: undefined;
|
|
136
162
|
const result = await runFix(state.root, {
|
|
137
163
|
apply: decision.apply,
|
|
138
164
|
noLlm: hasFlag(tokens, "--no-llm"),
|
|
139
|
-
confirm:
|
|
140
|
-
onProgress:
|
|
165
|
+
confirm: confirmFn,
|
|
166
|
+
onProgress: progressCallback(io, asJson),
|
|
141
167
|
});
|
|
142
168
|
if (asJson) {
|
|
143
169
|
write(io, fixToJson(result) + "\n\n");
|
|
@@ -149,11 +175,52 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
149
175
|
}
|
|
150
176
|
return;
|
|
151
177
|
}
|
|
178
|
+
if (cmd === "explain") {
|
|
179
|
+
if (!asJson) {
|
|
180
|
+
if (io.inkMode) {
|
|
181
|
+
const { noKeyNudgeMessage } = await import("./index-support.js");
|
|
182
|
+
const msg = noKeyNudgeMessage();
|
|
183
|
+
if (msg)
|
|
184
|
+
write(io, msg);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const { maybeOfferAiFixerSetup } = await import("./index-support.js");
|
|
188
|
+
await maybeOfferAiFixerSetup();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const { explainFindings } = await import("./engine/explain.js");
|
|
192
|
+
const usage = await meterOrPrint(io);
|
|
193
|
+
const { scan } = await import("./engine/scan.js");
|
|
194
|
+
const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
|
|
195
|
+
state.lastResult = scanResult;
|
|
196
|
+
const report = await explainFindings(scanResult, {
|
|
197
|
+
onProgress: progressCallback(io, asJson),
|
|
198
|
+
});
|
|
199
|
+
if (asJson) {
|
|
200
|
+
const { explainToJson } = await import("./ui/explain.js");
|
|
201
|
+
write(io, explainToJson(report) + "\n\n");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { renderExplain } = await import("./ui/explain.js");
|
|
205
|
+
write(io, renderExplain(report, usage) + "\n");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
152
208
|
if (cmd === "init") {
|
|
153
209
|
const { runInit } = await import("./engine/init.js");
|
|
154
210
|
const { renderInit } = await import("./ui/init.js");
|
|
155
211
|
const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
|
|
156
212
|
write(io, renderInit(result) + "\n");
|
|
213
|
+
const { maybeOfferAiFixerSetupDuringInit, noKeyNudgeMessage } = await import("./index-support.js");
|
|
214
|
+
await maybeOfferAiFixerSetupDuringInit(state.root, io.inkMode
|
|
215
|
+
? {
|
|
216
|
+
writePreamble: (text) => write(io, text),
|
|
217
|
+
offer: async () => {
|
|
218
|
+
const msg = noKeyNudgeMessage();
|
|
219
|
+
if (msg)
|
|
220
|
+
write(io, msg);
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: undefined);
|
|
157
224
|
return;
|
|
158
225
|
}
|
|
159
226
|
if (cmd === "config") {
|
|
@@ -207,6 +274,10 @@ async function handleConfig(tokens, io) {
|
|
|
207
274
|
return;
|
|
208
275
|
}
|
|
209
276
|
if (action === "set-key") {
|
|
277
|
+
if (io.inkMode) {
|
|
278
|
+
write(io, ` config set-key needs a real terminal prompt — run \`securevibe config set-key ${provider}\` as a one-shot command outside the REPL (or set the ${provider === "groq" ? "GROQ_API_KEY" : "ANTHROPIC_API_KEY"} env var directly).\n\n`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
210
281
|
const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
|
|
211
282
|
const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
|
|
212
283
|
if (!key) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from "../engine/types.js";
|
|
2
|
+
import { PR_COMMENT_MARKER } from "../engine/github.js";
|
|
3
|
+
const MAX_FINDINGS = 15;
|
|
4
|
+
const SEV_LABEL = {
|
|
5
|
+
critical: "CRITICAL",
|
|
6
|
+
high: "HIGH",
|
|
7
|
+
medium: "MEDIUM",
|
|
8
|
+
low: "LOW",
|
|
9
|
+
info: "INFO",
|
|
10
|
+
};
|
|
11
|
+
const READINESS_LABEL = {
|
|
12
|
+
ship: "✓ Ship",
|
|
13
|
+
warn: "⚠ Ship with warnings",
|
|
14
|
+
block: "✗ Block deploy",
|
|
15
|
+
};
|
|
16
|
+
function severityCounts(findings) {
|
|
17
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
18
|
+
for (const f of findings)
|
|
19
|
+
counts[f.severity]++;
|
|
20
|
+
return ["critical", "high", "medium", "low"]
|
|
21
|
+
.filter((s) => counts[s] > 0)
|
|
22
|
+
.map((s) => `**${counts[s]}** ${SEV_LABEL[s]}`)
|
|
23
|
+
.join(" · ");
|
|
24
|
+
}
|
|
25
|
+
export function renderPrComment(result) {
|
|
26
|
+
const L = [];
|
|
27
|
+
L.push(PR_COMMENT_MARKER);
|
|
28
|
+
L.push("## SecureVibe — changed-files scan");
|
|
29
|
+
L.push("");
|
|
30
|
+
L.push(`**Grade ${result.score.grade}** (${result.score.composite}/100) — ${READINESS_LABEL[result.score.readiness]}`);
|
|
31
|
+
L.push("");
|
|
32
|
+
if (result.findings.length === 0) {
|
|
33
|
+
L.push("No findings in the files changed by this PR. ✓");
|
|
34
|
+
return L.join("\n");
|
|
35
|
+
}
|
|
36
|
+
L.push(severityCounts(result.findings));
|
|
37
|
+
L.push("");
|
|
38
|
+
const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
|
|
39
|
+
const shown = sorted.slice(0, MAX_FINDINGS);
|
|
40
|
+
const remaining = sorted.length - shown.length;
|
|
41
|
+
for (const f of shown) {
|
|
42
|
+
L.push(`### ${SEV_LABEL[f.severity]} — ${f.title}`);
|
|
43
|
+
L.push(`\`${f.file}:${f.line}\``);
|
|
44
|
+
L.push("");
|
|
45
|
+
L.push(`**why:** ${f.why}`);
|
|
46
|
+
L.push("");
|
|
47
|
+
L.push(`**fix:** ${f.fix}`);
|
|
48
|
+
L.push("");
|
|
49
|
+
}
|
|
50
|
+
if (remaining > 0) {
|
|
51
|
+
L.push(`_+${remaining} more finding${remaining === 1 ? "" : "s"} — run \`securevibe scan\` locally to see all._`);
|
|
52
|
+
L.push("");
|
|
53
|
+
}
|
|
54
|
+
return L.join("\n");
|
|
55
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { mapAnswer } from "../prompt.js";
|
|
5
|
+
import { processLine } from "../../repl.js";
|
|
6
|
+
import { makeCallbackWritable } from "./outputStream.js";
|
|
7
|
+
import { filterCommands } from "./commandRegistry.js";
|
|
8
|
+
import { StatusLine } from "./StatusLine.js";
|
|
9
|
+
import { InputBox } from "./InputBox.js";
|
|
10
|
+
import { SlashMenu } from "./SlashMenu.js";
|
|
11
|
+
import { Spinner } from "./Spinner.js";
|
|
12
|
+
const h = React.createElement;
|
|
13
|
+
// Static<T> is generic; createElement can't infer T from an untyped call the
|
|
14
|
+
// way JSX's <Static<string>> would. Pin T=string via a TS instantiation
|
|
15
|
+
// expression instead of casting the props object.
|
|
16
|
+
const StringStatic = (Static);
|
|
17
|
+
/**
|
|
18
|
+
* Top-level Ink app: <Static> scrollback (everything dispatch() writes,
|
|
19
|
+
* unchanged from the plain-text output the legacy readline loop already
|
|
20
|
+
* produces) above a live bottom region (status line, spinner, slash menu,
|
|
21
|
+
* input box). Every submitted line goes through the exact same
|
|
22
|
+
* processLine() the legacy readline loop and repl.test.ts already exercise
|
|
23
|
+
* (Task 1) -- this component only changes how a line is captured and how
|
|
24
|
+
* output is framed, never what a command does.
|
|
25
|
+
*/
|
|
26
|
+
export function App({ initialPath }) {
|
|
27
|
+
const { exit } = useApp();
|
|
28
|
+
const stateRef = useRef({ root: path.resolve(initialPath) });
|
|
29
|
+
const [lines, setLines] = useState([]);
|
|
30
|
+
const [value, setValue] = useState("");
|
|
31
|
+
const [history, setHistory] = useState([]);
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const [progressMessage, setProgressMessage] = useState(null);
|
|
34
|
+
const [confirmKind, setConfirmKind] = useState(null);
|
|
35
|
+
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
|
|
36
|
+
const confirmResolverRef = useRef(null);
|
|
37
|
+
const pushLine = (text) => setLines((prev) => [...prev, text]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
(async () => {
|
|
40
|
+
const { applyStoredKeysToEnv } = await import("../../config.js");
|
|
41
|
+
await applyStoredKeysToEnv();
|
|
42
|
+
const { renderBanner } = await import("../banner.js");
|
|
43
|
+
pushLine(renderBanner({ subtitle: "interactive session — type `help` or `/`" }) + "\n");
|
|
44
|
+
pushLine(` target: ${stateRef.current.root}\n`);
|
|
45
|
+
})();
|
|
46
|
+
}, []);
|
|
47
|
+
const menuOpen = value.startsWith("/");
|
|
48
|
+
const filteredCommands = menuOpen ? filterCommands(value.slice(1)) : [];
|
|
49
|
+
const clampedIndex = Math.min(selectedMenuIndex, Math.max(0, filteredCommands.length - 1));
|
|
50
|
+
useInput((_input, key) => {
|
|
51
|
+
if (!menuOpen)
|
|
52
|
+
return;
|
|
53
|
+
if (key.upArrow)
|
|
54
|
+
setSelectedMenuIndex((i) => Math.max(0, i - 1));
|
|
55
|
+
if (key.downArrow)
|
|
56
|
+
setSelectedMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
|
|
57
|
+
if (key.escape)
|
|
58
|
+
setValue("");
|
|
59
|
+
});
|
|
60
|
+
const handleChange = (v) => {
|
|
61
|
+
setSelectedMenuIndex(0);
|
|
62
|
+
setValue(v);
|
|
63
|
+
};
|
|
64
|
+
const handleSubmit = async (line) => {
|
|
65
|
+
if (confirmResolverRef.current) {
|
|
66
|
+
const answer = mapAnswer(line, confirmKind ?? "patch");
|
|
67
|
+
confirmResolverRef.current(answer);
|
|
68
|
+
setValue("");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// A command is already running (busy stays true for its whole duration,
|
|
72
|
+
// including while a confirm is pending -- but that case already returned
|
|
73
|
+
// above). Ignore a second real submission rather than racing two
|
|
74
|
+
// processLine() calls over the same mutable ReplState. The legacy
|
|
75
|
+
// readline loop can't hit this: only one askLine() is ever pending, so
|
|
76
|
+
// there's no way to submit a second line before the first resolves.
|
|
77
|
+
if (busy)
|
|
78
|
+
return;
|
|
79
|
+
if (menuOpen) {
|
|
80
|
+
const chosen = filteredCommands[clampedIndex];
|
|
81
|
+
setValue(chosen ? chosen.name + " " : "");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!line.trim()) {
|
|
85
|
+
setValue("");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setHistory((prev) => [...prev, line]);
|
|
89
|
+
setValue("");
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setProgressMessage(null);
|
|
92
|
+
const io = {
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: makeCallbackWritable(pushLine),
|
|
95
|
+
inkMode: true,
|
|
96
|
+
onProgress: (m) => setProgressMessage(m),
|
|
97
|
+
makeConfirm: (render) => {
|
|
98
|
+
return (req) => new Promise((resolve) => {
|
|
99
|
+
pushLine(render(req) + "\n");
|
|
100
|
+
setConfirmKind(req.kind);
|
|
101
|
+
confirmResolverRef.current = (answer) => {
|
|
102
|
+
setConfirmKind(null);
|
|
103
|
+
confirmResolverRef.current = null;
|
|
104
|
+
resolve(answer);
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const shouldExit = await processLine(line, stateRef.current, io);
|
|
110
|
+
setBusy(false);
|
|
111
|
+
setProgressMessage(null);
|
|
112
|
+
if (shouldExit)
|
|
113
|
+
exit();
|
|
114
|
+
};
|
|
115
|
+
return h(React.Fragment, null, h(StringStatic, {
|
|
116
|
+
items: lines,
|
|
117
|
+
children: (line, i) => h(Text, { key: i }, line),
|
|
118
|
+
}), h(Box, { flexDirection: "column" }, h(StatusLine, { targetRoot: stateRef.current.root }), busy && progressMessage ? h(Spinner, { message: progressMessage }) : null, confirmKind
|
|
119
|
+
? h(Text, { dimColor: true }, confirmKind === "patch"
|
|
120
|
+
? " Apply this change? [y]es / [n]o / [a]ll / [q]uit"
|
|
121
|
+
: " Apply these supporting changes? [y]es / [n]o")
|
|
122
|
+
: null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
|
|
123
|
+
value,
|
|
124
|
+
onChange: handleChange,
|
|
125
|
+
onSubmit: handleSubmit,
|
|
126
|
+
history,
|
|
127
|
+
onHistoryNavigate: setValue,
|
|
128
|
+
menuOpen,
|
|
129
|
+
})));
|
|
130
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
/**
|
|
6
|
+
* Bordered single-line input (`>` prompt, rounded border). Up/down arrow
|
|
7
|
+
* cycles `history`, but only when the slash menu isn't open -- App.ts's own
|
|
8
|
+
* useInput takes over up/down for menu-item selection in that state.
|
|
9
|
+
* TextInput itself only handles left/right cursor movement and
|
|
10
|
+
* backspace/delete, never up/down, so there's no conflict between this
|
|
11
|
+
* hook and TextInput's own internal input handling.
|
|
12
|
+
*/
|
|
13
|
+
export function InputBox({ value, onChange, onSubmit, history, onHistoryNavigate, menuOpen }) {
|
|
14
|
+
const [historyIndex, setHistoryIndex] = useState(null);
|
|
15
|
+
useInput((_input, key) => {
|
|
16
|
+
if (menuOpen)
|
|
17
|
+
return;
|
|
18
|
+
if (key.upArrow) {
|
|
19
|
+
if (history.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
const next = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
22
|
+
setHistoryIndex(next);
|
|
23
|
+
onHistoryNavigate(history[next]);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.downArrow) {
|
|
27
|
+
if (historyIndex === null)
|
|
28
|
+
return;
|
|
29
|
+
const next = historyIndex + 1;
|
|
30
|
+
if (next >= history.length) {
|
|
31
|
+
setHistoryIndex(null);
|
|
32
|
+
onHistoryNavigate("");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
setHistoryIndex(next);
|
|
36
|
+
onHistoryNavigate(history[next]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return h(Box, { borderStyle: "round", paddingX: 1 }, h(Text, null, "> "), h(TextInput, {
|
|
41
|
+
value,
|
|
42
|
+
onChange: (v) => {
|
|
43
|
+
setHistoryIndex(null);
|
|
44
|
+
onChange(v);
|
|
45
|
+
},
|
|
46
|
+
onSubmit,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
/** Purely presentational -- App.ts owns navigation (up/down) and selection (Enter). */
|
|
5
|
+
export function SlashMenu({ items, selectedIndex }) {
|
|
6
|
+
if (items.length === 0) {
|
|
7
|
+
return h(Box, { paddingX: 1 }, h(Text, { dimColor: true }, "no matching commands"));
|
|
8
|
+
}
|
|
9
|
+
return h(Box, { flexDirection: "column", paddingX: 1 }, ...items.map((item, i) => h(Text, { key: item.name, color: i === selectedIndex ? "cyan" : undefined }, `${i === selectedIndex ? "› " : " "}/${item.name} ${item.args} — ${item.description}`)));
|
|
10
|
+
}
|