securevibe 0.1.10 → 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 +22 -7
- 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 +104 -58
- 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
|
@@ -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,19 @@ 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
|
+
|
|
48
57
|
## Always-on (doc 12)
|
|
49
58
|
|
|
50
59
|
`securevibe init` makes the tool continuous so insecure code can't slip through:
|
|
@@ -53,11 +62,16 @@ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-on
|
|
|
53
62
|
a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
|
|
54
63
|
clean version through);
|
|
55
64
|
- a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
|
|
56
|
-
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);
|
|
57
69
|
- gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
|
|
58
70
|
|
|
59
|
-
It is idempotent (existing files are left alone unless `--force`)
|
|
60
|
-
**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.
|
|
61
75
|
|
|
62
76
|
## The fix loop (doc 05)
|
|
63
77
|
|
|
@@ -121,8 +135,9 @@ against a local copy of the [OSV](https://osv.dev) advisory database, and report
|
|
|
121
135
|
CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
|
|
122
136
|
version while preserving your range prefix (`^`, `~`, `==`, ...).
|
|
123
137
|
- **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
|
|
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.
|
|
126
141
|
- **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
|
|
127
142
|
it means nothing matched the local database on that date, which is not a proof of safety.
|
|
128
143
|
|
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) => {
|
package/dist/repl.js
CHANGED
|
@@ -40,28 +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
|
-
" explain [--json] AI explanations for critical/high findings",
|
|
48
|
-
" ai-audit | protect | attack-map | score | deps | ready [--json]",
|
|
49
|
-
" init [--force] wire up the pre-commit guard + CI",
|
|
50
|
-
" config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
|
|
51
|
-
" db update | status manage the local OSV database",
|
|
52
|
-
" cd <path> change the scan target",
|
|
53
|
-
" pwd show the current scan target",
|
|
54
|
-
" help this message",
|
|
55
|
-
" exit | quit leave the session",
|
|
56
|
-
].join("\n");
|
|
57
|
-
/** 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. */
|
|
58
46
|
export async function runRepl(initialPath) {
|
|
59
47
|
if (!isInteractive()) {
|
|
60
48
|
process.stderr.write(" repl needs an interactive terminal.\n");
|
|
61
49
|
process.exitCode = 64;
|
|
62
50
|
return;
|
|
63
51
|
}
|
|
64
|
-
|
|
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;
|
|
65
99
|
}
|
|
66
100
|
/** The actual session loop, over injectable streams — testable without a TTY. */
|
|
67
101
|
export async function runReplSession(initialPath, io) {
|
|
@@ -73,39 +107,17 @@ export async function runReplSession(initialPath, io) {
|
|
|
73
107
|
write(io, ` target: ${state.root}\n\n`);
|
|
74
108
|
while (true) {
|
|
75
109
|
const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
continue;
|
|
79
|
-
const tokens = tokenize(line);
|
|
80
|
-
const cmd = tokens[0];
|
|
81
|
-
const rest = tokens.slice(1);
|
|
82
|
-
try {
|
|
83
|
-
if (cmd === "exit" || cmd === "quit" || cmd === "q")
|
|
84
|
-
break;
|
|
85
|
-
if (cmd === "help" || cmd === "?") {
|
|
86
|
-
write(io, HELP + "\n\n");
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (cmd === "pwd") {
|
|
90
|
-
write(io, ` ${state.root}\n\n`);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (cmd === "cd") {
|
|
94
|
-
if (!rest[0]) {
|
|
95
|
-
write(io, " usage: cd <path>\n\n");
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
state.root = path.resolve(state.root, rest[0]);
|
|
99
|
-
write(io, ` target: ${state.root}\n\n`);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
await dispatch(cmd, rest, state, io);
|
|
103
|
-
}
|
|
104
|
-
catch (err) {
|
|
105
|
-
write(io, ` error: ${err?.message ?? err}\n\n`);
|
|
106
|
-
}
|
|
110
|
+
if (await processLine(raw, state, io))
|
|
111
|
+
break;
|
|
107
112
|
}
|
|
108
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
|
+
}
|
|
109
121
|
async function dispatch(cmd, tokens, state, io) {
|
|
110
122
|
const asJson = hasFlag(tokens, "--json");
|
|
111
123
|
if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
|
|
@@ -113,7 +125,7 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
113
125
|
const usage = await meterOrPrint(io);
|
|
114
126
|
const result = await scan(state.root, {
|
|
115
127
|
aiOnly: cmd === "ai-audit",
|
|
116
|
-
onProgress:
|
|
128
|
+
onProgress: progressCallback(io, asJson),
|
|
117
129
|
});
|
|
118
130
|
state.lastResult = result;
|
|
119
131
|
if (asJson) {
|
|
@@ -130,15 +142,28 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
130
142
|
const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
|
|
131
143
|
const decision = decideApply(opts, true);
|
|
132
144
|
if (decision.apply && !hasFlag(tokens, "--no-llm")) {
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
|
135
155
|
}
|
|
136
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;
|
|
137
162
|
const result = await runFix(state.root, {
|
|
138
163
|
apply: decision.apply,
|
|
139
164
|
noLlm: hasFlag(tokens, "--no-llm"),
|
|
140
|
-
confirm:
|
|
141
|
-
onProgress:
|
|
165
|
+
confirm: confirmFn,
|
|
166
|
+
onProgress: progressCallback(io, asJson),
|
|
142
167
|
});
|
|
143
168
|
if (asJson) {
|
|
144
169
|
write(io, fixToJson(result) + "\n\n");
|
|
@@ -152,16 +177,24 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
152
177
|
}
|
|
153
178
|
if (cmd === "explain") {
|
|
154
179
|
if (!asJson) {
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
|
157
190
|
}
|
|
158
191
|
const { explainFindings } = await import("./engine/explain.js");
|
|
159
192
|
const usage = await meterOrPrint(io);
|
|
160
193
|
const { scan } = await import("./engine/scan.js");
|
|
161
|
-
const scanResult = await scan(state.root, { onProgress:
|
|
194
|
+
const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
|
|
162
195
|
state.lastResult = scanResult;
|
|
163
196
|
const report = await explainFindings(scanResult, {
|
|
164
|
-
onProgress:
|
|
197
|
+
onProgress: progressCallback(io, asJson),
|
|
165
198
|
});
|
|
166
199
|
if (asJson) {
|
|
167
200
|
const { explainToJson } = await import("./ui/explain.js");
|
|
@@ -177,8 +210,17 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
177
210
|
const { renderInit } = await import("./ui/init.js");
|
|
178
211
|
const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
|
|
179
212
|
write(io, renderInit(result) + "\n");
|
|
180
|
-
const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
|
|
181
|
-
await maybeOfferAiFixerSetupDuringInit(state.root
|
|
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);
|
|
182
224
|
return;
|
|
183
225
|
}
|
|
184
226
|
if (cmd === "config") {
|
|
@@ -232,6 +274,10 @@ async function handleConfig(tokens, io) {
|
|
|
232
274
|
return;
|
|
233
275
|
}
|
|
234
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
|
+
}
|
|
235
281
|
const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
|
|
236
282
|
const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
|
|
237
283
|
if (!key) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
/**
|
|
6
|
+
* One live-updating line (braille spinner + current step's message),
|
|
7
|
+
* replacing the old scrolling "· message" lines during scan/fix/explain.
|
|
8
|
+
* Hand-rolled rather than a dependency -- ten frames and a setInterval is
|
|
9
|
+
* simple enough not to justify another package.
|
|
10
|
+
*/
|
|
11
|
+
export function Spinner({ message }) {
|
|
12
|
+
const [frame, setFrame] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
15
|
+
return () => clearInterval(id);
|
|
16
|
+
}, []);
|
|
17
|
+
return h(Box, null, h(Text, { color: "cyan" }, FRAMES[frame]), h(Text, null, ` ${message}`));
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { llmAvailability } from "../../engine/fix/llm.js";
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
/**
|
|
7
|
+
* target: <basename> · fixer: <groq|anthropic|none>, right-aligned, dim.
|
|
8
|
+
* fixer reflects the same provider-selection precedence fix's LLM path
|
|
9
|
+
* already uses (llmAvailability), not a separately reimplemented rule.
|
|
10
|
+
*/
|
|
11
|
+
export function StatusLine({ targetRoot }) {
|
|
12
|
+
const avail = llmAvailability(false);
|
|
13
|
+
const fixer = avail.provider ?? "none";
|
|
14
|
+
return h(Box, { justifyContent: "flex-end" }, h(Text, { dimColor: true }, `target: ${path.basename(targetRoot)} · fixer: ${fixer}`));
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const COMMANDS = [
|
|
2
|
+
{ name: "scan", args: "[--json]", description: "full scan" },
|
|
3
|
+
{ name: "fix", args: "[--apply] [--yes] [--no-llm] [--json]", description: "fix findings" },
|
|
4
|
+
{ name: "explain", args: "[--json]", description: "AI explanations for critical/high findings" },
|
|
5
|
+
{ name: "ai-audit", args: "[--json]", description: "focus on the AI-agent attack surface" },
|
|
6
|
+
{ name: "protect", args: "[--json]", description: "AI execution firewall guidance" },
|
|
7
|
+
{ name: "attack-map", args: "[--json]", description: "map findings to attacker paths" },
|
|
8
|
+
{ name: "score", args: "[--json]", description: "score-only view" },
|
|
9
|
+
{ name: "deps", args: "[--json]", description: "dependency / CVE audit" },
|
|
10
|
+
{ name: "ready", args: "[--json]", description: "launch readiness scorecard" },
|
|
11
|
+
{ name: "init", args: "[--force]", description: "wire up the pre-commit guard + CI" },
|
|
12
|
+
{
|
|
13
|
+
name: "config",
|
|
14
|
+
args: "show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
|
|
15
|
+
description: "manage AI-fixer keys",
|
|
16
|
+
},
|
|
17
|
+
{ name: "db", args: "update | status", description: "manage the local OSV database" },
|
|
18
|
+
{ name: "cd", args: "<path>", description: "change the scan target" },
|
|
19
|
+
{ name: "pwd", args: "", description: "show the current scan target" },
|
|
20
|
+
{ name: "help", args: "", description: "this message" },
|
|
21
|
+
{ name: "exit", args: "", description: "leave the session (aliases: quit, q)" },
|
|
22
|
+
];
|
|
23
|
+
/** Prefix match on command name, case-insensitive. Empty query returns all. */
|
|
24
|
+
export function filterCommands(query) {
|
|
25
|
+
const q = query.toLowerCase();
|
|
26
|
+
return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(q));
|
|
27
|
+
}
|
|
28
|
+
/** Plain-text `help` output, one line per command. */
|
|
29
|
+
export function renderHelp() {
|
|
30
|
+
const lines = [" Commands:"];
|
|
31
|
+
for (const c of COMMANDS) {
|
|
32
|
+
const left = ` ${c.name}${c.args ? " " + c.args : ""}`;
|
|
33
|
+
lines.push(left.length < 44 ? left.padEnd(44) + c.description : left + " " + c.description);
|
|
34
|
+
}
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
/**
|
|
3
|
+
* A real Writable whose _write feeds every chunk to a callback instead of a
|
|
4
|
+
* terminal. Lets dispatch()'s existing `io.output.write(text)` calls feed
|
|
5
|
+
* Ink's <Static> scrollback region instead of writing directly to stdout,
|
|
6
|
+
* which would corrupt Ink's own live-region redraws (the input box, status
|
|
7
|
+
* line, spinner, and slash menu all redraw in place on every keystroke).
|
|
8
|
+
*/
|
|
9
|
+
export function makeCallbackWritable(onWrite) {
|
|
10
|
+
return new Writable({
|
|
11
|
+
write(chunk, _encoding, callback) {
|
|
12
|
+
onWrite(chunk.toString());
|
|
13
|
+
callback();
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Single source of truth for the CLI's own version, so it can't drift from
|
|
3
3
|
// what commander reports and what the update-check compares against.
|
|
4
4
|
// Kept in sync with the "version" field in package.json by hand at release time.
|
|
5
|
-
export const VERSION = "0.1.
|
|
5
|
+
export const VERSION = "0.1.12";
|
package/package.json
CHANGED
|
@@ -1,66 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "securevibe",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
-
"author": "Benedict Patrick <benedictroger2006@gmail.com>",
|
|
8
|
-
"homepage": "https://github.com/Benedictpatrick/secure-vibe#readme",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/Benedictpatrick/secure-vibe.git",
|
|
12
|
-
"directory": "packages/cli"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/Benedictpatrick/secure-vibe/issues"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"security",
|
|
19
|
-
"scanner",
|
|
20
|
-
"sast",
|
|
21
|
-
"ai security",
|
|
22
|
-
"prompt injection",
|
|
23
|
-
"vulnerability",
|
|
24
|
-
"secrets",
|
|
25
|
-
"sca",
|
|
26
|
-
"cve",
|
|
27
|
-
"launch readiness",
|
|
28
|
-
"vibe coding",
|
|
29
|
-
"cli"
|
|
30
|
-
],
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=20"
|
|
33
|
-
},
|
|
34
|
-
"bin": {
|
|
35
|
-
"securevibe": "./dist/index.js"
|
|
36
|
-
},
|
|
37
|
-
"files": [
|
|
38
|
-
"dist",
|
|
39
|
-
"data",
|
|
40
|
-
"LICENSE"
|
|
41
|
-
],
|
|
42
|
-
"scripts": {
|
|
43
|
-
"build": "tsc -p tsconfig.json",
|
|
44
|
-
"dev": "tsx src/index.ts",
|
|
45
|
-
"start": "node dist/index.js",
|
|
46
|
-
"test": "node run-tests.mjs"
|
|
47
|
-
},
|
|
48
|
-
"dependencies": {
|
|
49
|
-
"commander": "^12.1.0",
|
|
50
|
-
"diff": "^5.2.0",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
},
|
|
59
|
-
"
|
|
60
|
-
"@
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "securevibe",
|
|
3
|
+
"version": "0.1.12",
|
|
4
|
+
"description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"author": "Benedict Patrick <benedictroger2006@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/Benedictpatrick/secure-vibe#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Benedictpatrick/secure-vibe.git",
|
|
12
|
+
"directory": "packages/cli"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Benedictpatrick/secure-vibe/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"security",
|
|
19
|
+
"scanner",
|
|
20
|
+
"sast",
|
|
21
|
+
"ai security",
|
|
22
|
+
"prompt injection",
|
|
23
|
+
"vulnerability",
|
|
24
|
+
"secrets",
|
|
25
|
+
"sca",
|
|
26
|
+
"cve",
|
|
27
|
+
"launch readiness",
|
|
28
|
+
"vibe coding",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"securevibe": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"data",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"dev": "tsx src/index.ts",
|
|
45
|
+
"start": "node dist/index.js",
|
|
46
|
+
"test": "node run-tests.mjs"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"commander": "^12.1.0",
|
|
50
|
+
"diff": "^5.2.0",
|
|
51
|
+
"ink": "^6.8.0",
|
|
52
|
+
"ink-text-input": "^6.0.0",
|
|
53
|
+
"picocolors": "^1.1.1",
|
|
54
|
+
"react": "^19.2.7",
|
|
55
|
+
"tree-sitter-wasms": "^0.1.12",
|
|
56
|
+
"web-tree-sitter": "^0.25.10",
|
|
57
|
+
"yauzl": "^3.2.0"
|
|
58
|
+
},
|
|
59
|
+
"optionalDependencies": {
|
|
60
|
+
"@anthropic-ai/sdk": "^0.39.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/diff": "^5.2.0",
|
|
64
|
+
"@types/node": "^20.14.0",
|
|
65
|
+
"@types/react": "^19.2.17",
|
|
66
|
+
"@types/yauzl": "^2.10.3",
|
|
67
|
+
"ink-testing-library": "^4.0.0",
|
|
68
|
+
"tsx": "^4.19.0",
|
|
69
|
+
"typescript": "^5.6.0"
|
|
70
|
+
}
|
|
71
|
+
}
|