securevibe 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SecureVibe CLI (doc 12). Local-first: analysis runs on your machine.
4
+ * securevibe scan [path] full scan → findings + score
5
+ * securevibe ai-audit [path] focus on the AI-agent attack surface (doc 04)
6
+ * securevibe protect [path] remediation-first view of the fixes
7
+ * securevibe attack-map [path] exploit paths from reachable findings (doc 06)
8
+ * securevibe score [path] just the security score + readiness verdict
9
+ */
10
+ import { Command } from "commander";
11
+ import { scan } from "./engine/scan.js";
12
+ import { toJson, toSarif } from "./ui/emit.js";
13
+ const program = new Command();
14
+ program
15
+ .name("securevibe")
16
+ .description("Autonomous AI security engineer for AI-generated applications")
17
+ .version("0.1.0");
18
+ function addCommon(cmd) {
19
+ return cmd
20
+ .option("--json", "output machine-readable JSON")
21
+ .option("--sarif", "output SARIF 2.1.0 (for CI / GitHub code scanning)")
22
+ .option("--no-color", "disable coloured output")
23
+ .option("--staged", "scan only git-staged files (for pre-commit guards)")
24
+ .option("--ci", "CI mode: exit non-zero based on deployment readiness");
25
+ }
26
+ /**
27
+ * Free beta metering: one use per analysis command against the daily quota.
28
+ * The --staged commit guard is exempt — a quota must never block a commit.
29
+ */
30
+ async function meter(opts = {}) {
31
+ if (opts.staged)
32
+ return;
33
+ const { recordUse, limitMessage } = await import("./usage.js");
34
+ const r = await recordUse();
35
+ if (!r.allowed) {
36
+ process.stderr.write(limitMessage() + "\n");
37
+ process.exit(1);
38
+ }
39
+ }
40
+ /** Run a scan with stderr progress; honour --json/--sarif/--no-color. */
41
+ async function runScan(pathArg, opts, scanOpts = {}) {
42
+ if (opts.color === false)
43
+ process.env.NO_COLOR = "1";
44
+ await meter(opts);
45
+ const target = pathArg ?? ".";
46
+ const quiet = opts.json || opts.sarif;
47
+ // --staged: analyze only what's about to be committed (the pre-commit guard).
48
+ if (opts.staged) {
49
+ const path = await import("node:path");
50
+ const { listStagedFiles } = await import("./engine/git.js");
51
+ const absRoot = path.resolve(target);
52
+ const staged = await listStagedFiles(absRoot);
53
+ if (staged === null) {
54
+ if (!quiet)
55
+ process.stderr.write(" · not a git repo — scanning the whole tree\n");
56
+ }
57
+ else {
58
+ scanOpts = { ...scanOpts, files: staged };
59
+ }
60
+ }
61
+ const result = await scan(target, {
62
+ ...scanOpts,
63
+ onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
64
+ });
65
+ return result;
66
+ }
67
+ /** Emit machine output if requested; returns true if it handled output. */
68
+ function emitMachine(result, opts) {
69
+ if (opts.sarif) {
70
+ process.stdout.write(toSarif(result) + "\n");
71
+ return true;
72
+ }
73
+ if (opts.json) {
74
+ process.stdout.write(toJson(result) + "\n");
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ /** Exit code policy for CI gating (doc 08/12). */
80
+ function ciExit(result, opts) {
81
+ if (!opts.ci)
82
+ return;
83
+ const r = result.score.readiness;
84
+ if (r === "block")
85
+ process.exit(2);
86
+ if (r === "warn")
87
+ process.exit(1);
88
+ process.exit(0);
89
+ }
90
+ /** After a human-readable scan, nudge toward `fix` if anything is blocking. */
91
+ function fixHint(result, opts) {
92
+ if (opts.json || opts.sarif)
93
+ return;
94
+ if (result.score.readiness === "ship")
95
+ return;
96
+ process.stdout.write(` → run \`securevibe fix\` to auto-harden the blocking issues (dry run by default)\n\n`);
97
+ }
98
+ addCommon(program
99
+ .command("scan", { isDefault: true })
100
+ .description("Scan a repository for vulnerabilities and AI-agent risks")
101
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
102
+ const result = await runScan(pathArg, opts);
103
+ if (!emitMachine(result, opts)) {
104
+ const { renderReport } = await import("./ui/report.js");
105
+ process.stdout.write(renderReport(result) + "\n");
106
+ fixHint(result, opts);
107
+ }
108
+ ciExit(result, opts);
109
+ });
110
+ program
111
+ .command("init")
112
+ .description("Wire SecureVibe into this project: pre-commit guard + CI + gitignore (doc 12)")
113
+ .argument("[path]", "path to the repository", ".")
114
+ .option("--force", "overwrite an existing hook / workflow / config")
115
+ .option("--no-color", "disable coloured output")
116
+ .action(async (pathArg, opts) => {
117
+ if (opts.color === false)
118
+ process.env.NO_COLOR = "1";
119
+ const { runInit } = await import("./engine/init.js");
120
+ const { renderInit } = await import("./ui/init.js");
121
+ const result = await runInit(pathArg ?? ".", { force: opts.force });
122
+ process.stdout.write(renderInit(result) + "\n");
123
+ });
124
+ program
125
+ .command("fix")
126
+ .description("Autonomously fix findings, then re-scan to verify (doc 05)")
127
+ .argument("[path]", "path to the repository", ".")
128
+ .option("--apply", "write the fixes to disk (asks before each change unless --yes)")
129
+ .option("--yes", "apply every verified fix without prompting (for CI / scripts)")
130
+ .option("--no-llm", "disable the optional Claude-powered fixer")
131
+ .option("--json", "output machine-readable JSON")
132
+ .option("--no-color", "disable coloured output")
133
+ .action(async (pathArg, opts) => {
134
+ if (opts.color === false)
135
+ process.env.NO_COLOR = "1";
136
+ await meter();
137
+ const { runFix } = await import("./engine/fix/session.js");
138
+ const { isInteractive, decideApply, makeFixConfirm } = await import("./ui/prompt.js");
139
+ const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
140
+ const decision = decideApply(opts, isInteractive());
141
+ const result = await runFix(pathArg ?? ".", {
142
+ apply: decision.apply,
143
+ noLlm: opts.llm === false,
144
+ confirm: decision.prompt ? makeFixConfirm(renderApprovalRequest) : undefined,
145
+ onProgress: opts.json ? undefined : (m) => process.stderr.write(` · ${m}\n`),
146
+ });
147
+ if (opts.json)
148
+ process.stdout.write(fixToJson(result) + "\n");
149
+ else
150
+ process.stdout.write(renderFixSession(result) + "\n");
151
+ if (decision.notice) {
152
+ process.stderr.write(" Not applied: no interactive terminal. Re-run with --yes to apply non-interactively.\n");
153
+ }
154
+ });
155
+ addCommon(program
156
+ .command("ai-audit")
157
+ .description("Focus on the AI-application / agent attack surface (doc 04)")
158
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
159
+ const result = await runScan(pathArg, opts, { aiOnly: true });
160
+ if (!emitMachine(result, opts)) {
161
+ const { renderReport } = await import("./ui/report.js");
162
+ process.stdout.write(renderReport(result, { aiFocus: true }) + "\n");
163
+ }
164
+ ciExit(result, opts);
165
+ });
166
+ addCommon(program
167
+ .command("protect")
168
+ .description("Remediation-first view: the fixes for every finding")
169
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
170
+ const result = await runScan(pathArg, opts);
171
+ if (!emitMachine(result, opts)) {
172
+ const { renderRemediations } = await import("./ui/protect.js");
173
+ process.stdout.write(renderRemediations(result) + "\n");
174
+ }
175
+ ciExit(result, opts);
176
+ });
177
+ addCommon(program
178
+ .command("attack-map")
179
+ .description("Show exploit paths from reachable findings (doc 06)")
180
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
181
+ const result = await runScan(pathArg, opts);
182
+ if (!emitMachine(result, opts)) {
183
+ const { renderAttackMap } = await import("./ui/attackmap.js");
184
+ process.stdout.write(renderAttackMap(result) + "\n");
185
+ }
186
+ ciExit(result, opts);
187
+ });
188
+ addCommon(program
189
+ .command("score")
190
+ .description("Print the security score and deployment-readiness verdict")
191
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
192
+ const result = await runScan(pathArg, opts);
193
+ if (!emitMachine(result, opts)) {
194
+ const { renderScoreOnly } = await import("./ui/protect.js");
195
+ process.stdout.write(renderScoreOnly(result) + "\n");
196
+ }
197
+ ciExit(result, opts);
198
+ });
199
+ addCommon(program
200
+ .command("deps")
201
+ .description("Audit dependencies for known CVEs (SCA, offline against the local OSV db)")
202
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
203
+ const result = await runScan(pathArg, opts);
204
+ if (!emitMachine(result, opts)) {
205
+ const { renderDeps } = await import("./ui/deps.js");
206
+ process.stdout.write(renderDeps(result) + "\n");
207
+ }
208
+ ciExit(result, opts);
209
+ });
210
+ addCommon(program
211
+ .command("ready")
212
+ .description("Launch-readiness scorecard: pass/fail gates + a go/no-go verdict")
213
+ .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
214
+ const result = await runScan(pathArg, opts);
215
+ if (!emitMachine(result, opts)) {
216
+ const { renderScorecard } = await import("./ui/readiness.js");
217
+ process.stdout.write(renderScorecard(result) + "\n");
218
+ }
219
+ ciExit(result, opts);
220
+ });
221
+ program
222
+ .command("db")
223
+ .description("Manage the local OSV vulnerability database (the only networked command)")
224
+ .argument("<action>", "update | status")
225
+ .option("--no-color", "disable coloured output")
226
+ .action(async (action, opts) => {
227
+ if (opts.color === false)
228
+ process.env.NO_COLOR = "1";
229
+ if (action === "status") {
230
+ const { loadDepDb, userDbDir } = await import("./engine/deps/db.js");
231
+ const db = await loadDepDb();
232
+ const ecos = Object.entries(db.ecosystems)
233
+ .map(([e, pkgs]) => `${e}: ${Object.keys(pkgs).length} packages`)
234
+ .join(", ");
235
+ process.stdout.write(` SecureVibe OSV database\n date: ${db.date ?? "(bundled seed only)"}\n` +
236
+ ` cache: ${userDbDir()}\n loaded: ${ecos || "(empty)"}\n`);
237
+ return;
238
+ }
239
+ if (action === "update") {
240
+ const { updateDb } = await import("./engine/deps/osv.js");
241
+ process.stderr.write(" · fetching OSV dumps (npm, PyPI)…\n");
242
+ try {
243
+ const r = await updateDb();
244
+ process.stdout.write(` Updated local OSV db (${r.date}): ${r.count} packages → ${r.destDir}\n`);
245
+ }
246
+ catch (err) {
247
+ process.stderr.write(` db update failed: ${err?.message ?? err}\n`);
248
+ process.exit(1);
249
+ }
250
+ return;
251
+ }
252
+ process.stderr.write(` unknown db action: ${action} (use: update | status)\n`);
253
+ process.exit(64);
254
+ });
255
+ program.parseAsync(process.argv).catch((err) => {
256
+ process.stderr.write(`securevibe: ${err?.message ?? err}\n`);
257
+ process.exit(70);
258
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `attack-map` view (doc 06, MVP). Renders exploit paths from reachable
3
+ * findings: external entrypoint → exploited weakness → asset/impact. The full
4
+ * platform builds a weighted graph in Neo4j and computes choke points; this MVP
5
+ * surfaces the reachable critical/high chains in text.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ function impactOf(f) {
10
+ switch (f.detector) {
11
+ case "sql-injection":
12
+ return "database read/write/exfiltration";
13
+ case "code-execution":
14
+ return "arbitrary code / OS command execution";
15
+ case "broken-access-control":
16
+ return "other users' data (IDOR)";
17
+ case "ai-tool-hijack":
18
+ return "agent performs attacker-chosen privileged actions";
19
+ case "ai-overprivileged-tool":
20
+ return "filesystem / shell / DB via the model";
21
+ case "hardcoded-secret":
22
+ return "credential reuse → downstream systems";
23
+ case "xss":
24
+ return "victim session / browser compromise";
25
+ default:
26
+ return "compromise of " + f.file;
27
+ }
28
+ }
29
+ export function renderAttackMap(result) {
30
+ const lines = [];
31
+ lines.push(renderBanner({ subtitle: "attack map · exploit paths" }));
32
+ lines.push("");
33
+ const chains = result.findings.filter((f) => f.reachable && (f.severity === "critical" || f.severity === "high"));
34
+ if (chains.length === 0) {
35
+ lines.push(pc.green(" ✓ No reachable critical/high exploit paths found."));
36
+ lines.push("");
37
+ return lines.join("\n");
38
+ }
39
+ lines.push(pc.dim(` ${chains.length} reachable exploit path(s), highest impact first:`));
40
+ lines.push("");
41
+ for (const f of chains) {
42
+ const entry = f.category === "ai" ? "untrusted prompt / input" : "external request";
43
+ const node = (s) => pc.bold(s);
44
+ lines.push(" " +
45
+ pc.red("●") +
46
+ " " +
47
+ node(entry) +
48
+ pc.dim(" ──▶ ") +
49
+ node(f.title) +
50
+ pc.dim(" ──▶ ") +
51
+ pc.red(node(impactOf(f))));
52
+ lines.push(` ${pc.dim(`${f.file}:${f.line} · ${f.owasp ?? f.cwe ?? ""} · ${f.mitre ?? ""}`)}`);
53
+ lines.push("");
54
+ }
55
+ // Crude "choke point": the file implicated in the most chains.
56
+ const byFile = new Map();
57
+ for (const f of chains)
58
+ byFile.set(f.file, (byFile.get(f.file) ?? 0) + 1);
59
+ const top = [...byFile.entries()].sort((a, b) => b[1] - a[1])[0];
60
+ if (top && top[1] > 1) {
61
+ lines.push(pc.dim(" ─────────────────────────────────────────────────"));
62
+ lines.push(` ${pc.yellow("◆ choke point:")} ${pc.cyan(top[0])} appears in ${top[1]} paths — fix it first.`);
63
+ lines.push("");
64
+ }
65
+ return lines.join("\n");
66
+ }
@@ -0,0 +1,68 @@
1
+ // packages/cli/src/ui/banner.ts
2
+ /**
3
+ * The SecureVibe wordmark banner shown atop human-readable command output.
4
+ * ANSI-Shadow block art with a cyan→magenta truecolor gradient (the brand
5
+ * colors). Degrades cleanly: NO_COLOR drops the gradient, and a terminal too
6
+ * narrow for the art falls back to a one-line header. Suppressed entirely for
7
+ * machine output (--json / --sarif / --ci) by the caller.
8
+ *
9
+ * Colour is handled with raw escapes (not picocolors) so it honours NO_COLOR at
10
+ * call time — picocolors locks colour support at import, before the CLI sets
11
+ * NO_COLOR for --no-color.
12
+ */
13
+ const ART = [
14
+ "███████╗███████╗ ██████╗██╗ ██╗██████╗ ███████╗██╗ ██╗██╗██████╗ ███████╗",
15
+ "██╔════╝██╔════╝██╔════╝██║ ██║██╔══██╗██╔════╝██║ ██║██║██╔══██╗██╔════╝",
16
+ "███████╗█████╗ ██║ ██║ ██║██████╔╝█████╗ ██║ ██║██║██████╔╝█████╗ ",
17
+ "╚════██║██╔══╝ ██║ ██║ ██║██╔══██╗██╔══╝ ╚██╗ ██╔╝██║██╔══██╗██╔══╝ ",
18
+ "███████║███████╗╚██████╗╚██████╔╝██║ ██║███████╗ ╚████╔╝ ██║██████╔╝███████╗",
19
+ "╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝",
20
+ ];
21
+ const TAGLINE = "autonomous AI security engineer";
22
+ const ART_WIDTH = Math.max(...ART.map((l) => l.length));
23
+ /** cyan (0,255,255) → magenta (255,0,255): r ramps up, g ramps down, b held. */
24
+ function gradientColor(t) {
25
+ return [Math.round(255 * t), Math.round(255 * (1 - t)), 255];
26
+ }
27
+ /** Colour each non-space cell of a line by its column position. */
28
+ function gradientLine(line) {
29
+ const n = Math.max(ART_WIDTH - 1, 1);
30
+ let out = "";
31
+ for (let i = 0; i < line.length; i++) {
32
+ const ch = line[i];
33
+ if (ch === " ") {
34
+ out += " ";
35
+ continue;
36
+ }
37
+ const [r, g, b] = gradientColor(i / n);
38
+ out += `\x1b[38;2;${r};${g};${b}m${ch}`;
39
+ }
40
+ return out + "\x1b[0m";
41
+ }
42
+ /** A short gradient of plain text (used by the compact fallback header). */
43
+ function gradientText(text) {
44
+ const n = Math.max(text.length - 1, 1);
45
+ let out = "";
46
+ for (let i = 0; i < text.length; i++) {
47
+ const [r, g, b] = gradientColor(i / n);
48
+ out += `\x1b[38;2;${r};${g};${b}m${text[i]}`;
49
+ }
50
+ return out + "\x1b[0m";
51
+ }
52
+ const dim = (s) => `\x1b[2m${s}\x1b[22m`;
53
+ /** Render the banner block (no trailing newline). */
54
+ export function renderBanner(opts = {}) {
55
+ const noColor = !!process.env.NO_COLOR;
56
+ const cols = opts.columns ?? process.stdout.columns ?? 80;
57
+ const tag = TAGLINE + (opts.subtitle ? ` · ${opts.subtitle}` : "");
58
+ // Too narrow for the art → a single tasteful header line.
59
+ if (cols < ART_WIDTH + 2) {
60
+ const mark = noColor ? "SecureVibe" : gradientText("SecureVibe");
61
+ return `\n ◇ ${mark} ${noColor ? tag : dim(tag)}`;
62
+ }
63
+ const L = [""];
64
+ for (const line of ART)
65
+ L.push(" " + (noColor ? line : gradientLine(line)));
66
+ L.push(" " + (noColor ? tag : dim(tag)));
67
+ return L.join("\n");
68
+ }
@@ -0,0 +1,54 @@
1
+ // packages/cli/src/ui/deps.ts
2
+ /**
3
+ * Dependency (SCA) view. The dependency dimension is reported separately from
4
+ * the code score — counts of known CVEs with the database date, honest framing:
5
+ * "no known advisories as of <db date>" is not a proof of safety.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ const SEV_COLOR = {
10
+ critical: (s) => pc.bgRed(pc.white(pc.bold(` ${s} `))),
11
+ high: (s) => pc.red(pc.bold(s)),
12
+ medium: (s) => pc.yellow(s),
13
+ low: (s) => pc.blue(s),
14
+ info: (s) => pc.dim(s),
15
+ };
16
+ export function dependencyLine(result) {
17
+ const d = result.dependencies;
18
+ if (!d)
19
+ return "";
20
+ const date = d.dbDate ? `db ${d.dbDate}` : "db: run `securevibe db update`";
21
+ if (d.total === 0)
22
+ return pc.dim(`no known advisories as of ${d.dbDate ?? "(no db)"}`);
23
+ const parts = ["critical", "high", "medium", "low"]
24
+ .filter((s) => d.bySeverity[s] > 0)
25
+ .map((s) => `${d.bySeverity[s]} ${s}`);
26
+ return `${d.total} known ${d.total === 1 ? "CVE" : "CVEs"} (${parts.join(", ")}) · ${date}`;
27
+ }
28
+ export function renderDeps(result) {
29
+ const lines = [];
30
+ const deps = result.findings.filter((f) => f.detector === "vulnerable-dependency");
31
+ lines.push(renderBanner({ subtitle: "dependency audit (SCA)" }));
32
+ lines.push(pc.dim(` ${result.root}`));
33
+ lines.push("");
34
+ lines.push(" " + (deps.length === 0 ? pc.green("✓ ") : "") + dependencyLine(result));
35
+ lines.push("");
36
+ for (const f of deps)
37
+ lines.push(...renderDepFinding(f));
38
+ if (deps.length > 0) {
39
+ lines.push(pc.dim(" ─────────────────────────────────────────────────"));
40
+ lines.push(" " + pc.bold("Next:") + pc.dim(" run ") + pc.cyan("securevibe fix") + pc.dim(" to bump vulnerable packages (review + test the diff)."));
41
+ }
42
+ lines.push(pc.dim(" A clean result means no known advisories in the local database, not proof of safety."));
43
+ lines.push("");
44
+ return lines.join("\n");
45
+ }
46
+ function renderDepFinding(f) {
47
+ return [
48
+ ` ${SEV_COLOR[f.severity](f.severity.toUpperCase())} ${pc.bold(f.title)}`,
49
+ ` ${pc.dim("↳")} ${pc.cyan(`${f.file}:${f.line}`)} ${pc.dim([f.cwe, f.owasp].filter(Boolean).join(" · "))}`,
50
+ ` ${pc.dim("why:")} ${f.why}`,
51
+ ` ${pc.green("fix:")} ${f.fix}`,
52
+ "",
53
+ ];
54
+ }
@@ -0,0 +1,62 @@
1
+ export function toJson(result) {
2
+ return JSON.stringify(result, null, 2);
3
+ }
4
+ function sarifLevel(sev) {
5
+ if (sev === "critical" || sev === "high")
6
+ return "error";
7
+ if (sev === "medium")
8
+ return "warning";
9
+ return "note";
10
+ }
11
+ /** SARIF 2.1.0 — uploadable to GitHub code scanning and other tools. */
12
+ export function toSarif(result) {
13
+ const ruleIds = new Map();
14
+ for (const f of result.findings)
15
+ if (!ruleIds.has(f.detector))
16
+ ruleIds.set(f.detector, f);
17
+ const rules = [...ruleIds.values()].map((f) => ({
18
+ id: f.detector,
19
+ name: f.detector,
20
+ shortDescription: { text: f.title },
21
+ properties: { tags: [f.category, f.owasp, f.cwe].filter(Boolean) },
22
+ }));
23
+ const results = result.findings.map((f) => ({
24
+ ruleId: f.detector,
25
+ level: sarifLevel(f.severity),
26
+ message: { text: `${f.title}. ${f.why} Fix: ${f.fix}` },
27
+ locations: [
28
+ {
29
+ physicalLocation: {
30
+ artifactLocation: { uri: f.file },
31
+ region: { startLine: f.line, startColumn: f.column },
32
+ },
33
+ },
34
+ ],
35
+ properties: {
36
+ severity: f.severity,
37
+ confidence: f.confidence,
38
+ reachable: f.reachable,
39
+ cwe: f.cwe,
40
+ owasp: f.owasp,
41
+ mitre: f.mitre,
42
+ },
43
+ }));
44
+ const sarif = {
45
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
46
+ version: "2.1.0",
47
+ runs: [
48
+ {
49
+ tool: {
50
+ driver: {
51
+ name: "SecureVibe",
52
+ informationUri: "https://securevibe.dev",
53
+ version: "0.1.0",
54
+ rules,
55
+ },
56
+ },
57
+ results,
58
+ },
59
+ ],
60
+ };
61
+ return JSON.stringify(sarif, null, 2);
62
+ }