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/ui/fix.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `fix` view (doc 05). Shows the before→after score, a colored unified diff for
3
+ * every accepted rewrite, and an honest breakdown of what was auto-fixed vs.
4
+ * what needs human review. The copy is deliberately precise: we never claim the
5
+ * app is "secure" — only that our detector no longer flags the issue.
6
+ */
7
+ import pc from "picocolors";
8
+ import { structuredPatch } from "diff";
9
+ import { renderBanner } from "./banner.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
+ function gradeColor(grade) {
18
+ if (grade === "A" || grade === "B")
19
+ return pc.green;
20
+ if (grade === "C")
21
+ return pc.yellow;
22
+ return pc.red;
23
+ }
24
+ function readinessLabel(r) {
25
+ if (r === "ship")
26
+ return pc.green(pc.bold("SHIP"));
27
+ if (r === "warn")
28
+ return pc.yellow(pc.bold("WARN"));
29
+ return pc.red(pc.bold("BLOCK"));
30
+ }
31
+ function scoreBadge(s) {
32
+ return `${gradeColor(s.grade)(pc.bold(` ${s.grade} `))} ${s.composite}/100 ${readinessLabel(s.readiness)}`;
33
+ }
34
+ export function renderFixSession(result) {
35
+ const L = [];
36
+ L.push(renderBanner({ subtitle: result.apply ? "fix · applying" : "fix · dry run (no files changed)" }));
37
+ L.push("");
38
+ // Score transition.
39
+ L.push(" " + scoreBadge(result.scoreBefore) + pc.dim(" → ") + scoreBadge(result.scoreAfter));
40
+ L.push("");
41
+ const fixed = result.outcomes.filter((o) => o.status === "fixed");
42
+ const manual = result.outcomes.filter((o) => o.status === "manual");
43
+ const skipped = result.outcomes.filter((o) => o.status === "skipped");
44
+ if (result.outcomes.length === 0) {
45
+ L.push(pc.green(" ✓ Nothing to fix — no findings."));
46
+ L.push("");
47
+ return L.join("\n");
48
+ }
49
+ const counts = [pc.green(`${fixed.length} auto-fixed`)];
50
+ if (skipped.length > 0)
51
+ counts.push(pc.dim(`${skipped.length} skipped`));
52
+ counts.push(pc.yellow(`${manual.length} need manual review`));
53
+ L.push(" " + counts.join(" · ") +
54
+ (result.llmAvailable ? pc.dim(" · Claude fixer: on") : pc.dim(" · Claude fixer: off")));
55
+ L.push("");
56
+ // Diffs for accepted patches.
57
+ for (const p of result.patches) {
58
+ L.push(" " + pc.cyan(pc.bold(p.relPath)) + pc.dim(` (${p.targeted.length} fix${p.targeted.length === 1 ? "" : "es"})`));
59
+ L.push(renderDiff(p.relPath, p.before, p.after));
60
+ L.push("");
61
+ }
62
+ // Auto-fixed list — what actually landed (or would land, on a dry run).
63
+ if (fixed.length > 0) {
64
+ L.push(pc.bold(pc.green(result.apply ? " Fixed" : " Will fix")));
65
+ for (const o of fixed)
66
+ L.push(" " + outcomeLine(o));
67
+ L.push("");
68
+ }
69
+ // Declined list — changes you skipped at the prompt, so nothing silently vanishes.
70
+ if (skipped.length > 0) {
71
+ L.push(pc.bold(pc.dim(" Skipped — you declined these (still unfixed)")));
72
+ for (const o of skipped)
73
+ L.push(" " + outcomeLine(o));
74
+ L.push("");
75
+ }
76
+ // Manual list.
77
+ if (manual.length > 0) {
78
+ L.push(pc.bold(pc.yellow(" Needs manual review")));
79
+ for (const o of manual) {
80
+ L.push(" " + outcomeLine(o));
81
+ L.push(" " + pc.dim(o.note));
82
+ }
83
+ L.push("");
84
+ }
85
+ // Side effects.
86
+ if (result.sideEffects.length > 0) {
87
+ L.push(pc.bold(" Hardening side effects"));
88
+ for (const fx of result.sideEffects) {
89
+ const verb = fx.kind === "ensure-line" ? `ensure \`${fx.content}\` in` : "create";
90
+ L.push(` ${pc.dim(result.apply ? "•" : "○")} ${verb} ${pc.cyan(fx.relPath)} ${pc.dim(`— ${fx.reason}`)}`);
91
+ }
92
+ L.push("");
93
+ }
94
+ // Footer: backup + the integrity disclaimer + next step.
95
+ L.push(pc.dim(" ─────────────────────────────────────────────────"));
96
+ if (result.apply) {
97
+ if (result.backupDir) {
98
+ L.push(` ${pc.green("✓ Applied.")} Originals backed up to ${pc.cyan(rel(result.root, result.backupDir))}`);
99
+ }
100
+ else {
101
+ L.push(` ${pc.green("✓ Applied.")}`);
102
+ }
103
+ }
104
+ else {
105
+ L.push(` ${pc.dim("Dry run — no files were changed.")} Re-run with ${pc.bold("--apply")} to write these fixes.`);
106
+ }
107
+ L.push(pc.dim(" These fixes mean the detector no longer flags the issue — not that the app is proven secure. " +
108
+ "Review every diff and run your tests before deploying."));
109
+ if (!result.llmAvailable && result.outcomes.some((o) => o.status === "manual")) {
110
+ L.push(pc.dim(" Set ANTHROPIC_API_KEY to auto-fix logic-level issues (RCE, AI tool hijack, IDOR)."));
111
+ }
112
+ L.push("");
113
+ return L.join("\n");
114
+ }
115
+ /** Render one approval request (a patch diff + verification note, or the side-effects list). */
116
+ export function renderApprovalRequest(req) {
117
+ const L = [];
118
+ if (req.kind === "patch") {
119
+ const p = req.patch;
120
+ const titles = p.targeted.map((f) => f.title).join(", ");
121
+ L.push("");
122
+ L.push(` ${pc.bold(`Fix ${req.index + 1} of ${req.total}`)} — ${pc.cyan(p.relPath)} ` +
123
+ pc.dim(`[${p.targeted.length} finding${p.targeted.length === 1 ? "" : "s"}: ${titles}]`));
124
+ L.push(renderDiff(p.relPath, p.before, p.after));
125
+ L.push(" " + pc.dim("verified: finding clears · no new issue · file still parses"));
126
+ }
127
+ else {
128
+ L.push("");
129
+ L.push(" " + pc.bold("Supporting changes") + pc.dim(" (additive, no break risk)"));
130
+ for (const fx of req.effects) {
131
+ const verb = fx.kind === "ensure-line" ? `ensure \`${fx.content}\` in` : "create";
132
+ L.push(` ${pc.dim("○")} ${verb} ${pc.cyan(fx.relPath)} ${pc.dim(`— ${fx.reason}`)}`);
133
+ }
134
+ }
135
+ return L.join("\n");
136
+ }
137
+ function outcomeLine(o) {
138
+ const sev = SEV_TAG[o.finding.severity](` ${o.finding.severity.toUpperCase()} `);
139
+ const strat = o.strategy ? pc.dim(`[${o.strategy === "llm" ? "claude" : "rule"}]`) : "";
140
+ return `${sev} ${o.finding.title} ${pc.dim(`(${o.finding.file}:${o.finding.line})`)} ${strat}`.trimEnd();
141
+ }
142
+ /** Compact colored unified diff (2 lines of context). */
143
+ function renderDiff(relPath, before, after) {
144
+ const patch = structuredPatch(relPath, relPath, before, after, "", "", { context: 2 });
145
+ const out = [];
146
+ for (const h of patch.hunks) {
147
+ out.push(" " + pc.dim(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`));
148
+ for (const line of h.lines) {
149
+ if (line.startsWith("+"))
150
+ out.push(" " + pc.green(line));
151
+ else if (line.startsWith("-"))
152
+ out.push(" " + pc.red(line));
153
+ else
154
+ out.push(" " + pc.dim(line));
155
+ }
156
+ }
157
+ return out.join("\n");
158
+ }
159
+ function rel(root, abs) {
160
+ return abs.startsWith(root) ? abs.slice(root.length).replace(/^[\\/]/, "") : abs;
161
+ }
162
+ /** Machine-readable summary for `fix --json`. */
163
+ export function fixToJson(result) {
164
+ return JSON.stringify({
165
+ root: result.root,
166
+ applied: result.apply,
167
+ llmAvailable: result.llmAvailable,
168
+ scoreBefore: result.scoreBefore,
169
+ scoreAfter: result.scoreAfter,
170
+ backupDir: result.backupDir,
171
+ summary: {
172
+ fixed: result.outcomes.filter((o) => o.status === "fixed").length,
173
+ manual: result.outcomes.filter((o) => o.status === "manual").length,
174
+ },
175
+ outcomes: result.outcomes.map((o) => ({
176
+ id: o.finding.id,
177
+ detector: o.finding.detector,
178
+ severity: o.finding.severity,
179
+ file: o.finding.file,
180
+ line: o.finding.line,
181
+ status: o.status,
182
+ strategy: o.strategy ?? null,
183
+ note: o.note,
184
+ })),
185
+ patches: result.patches.map((p) => ({ file: p.relPath, fixes: p.targeted.length })),
186
+ sideEffects: result.sideEffects,
187
+ }, null, 2);
188
+ }
@@ -0,0 +1,34 @@
1
+ /** `init` view (doc 12): what was wired in, and what to do next. */
2
+ import pc from "picocolors";
3
+ const MARK = {
4
+ created: pc.green("+"),
5
+ updated: pc.cyan("↻"),
6
+ skipped: pc.dim("•"),
7
+ };
8
+ export function renderInit(result) {
9
+ const L = [];
10
+ L.push("");
11
+ L.push(pc.bold(pc.cyan(" ◇ SecureVibe init")) + pc.dim(" · always-on security for this project"));
12
+ L.push("");
13
+ for (const a of result.actions) {
14
+ const label = a.status === "skipped" ? pc.dim(a.target) : pc.bold(a.target);
15
+ L.push(` ${MARK[a.status]} ${label} ${pc.dim("— " + a.note)}`);
16
+ }
17
+ L.push("");
18
+ L.push(pc.dim(" ─────────────────────────────────────────────────"));
19
+ if (result.isGitRepo) {
20
+ L.push(` ${pc.green("✓")} Commits are now guarded — a commit that introduces a blocking issue (or leaks a secret) is rejected.`);
21
+ }
22
+ else {
23
+ L.push(` ${pc.yellow("!")} Not a git repo yet. Run ${pc.bold("git init")} then ${pc.bold("securevibe init")} to install the commit guard.`);
24
+ }
25
+ L.push("");
26
+ L.push(pc.bold(" Next steps"));
27
+ L.push(` 1. ${pc.bold("securevibe scan")} — see where you stand`);
28
+ L.push(` 2. ${pc.bold("securevibe fix --apply")} — auto-harden the blocking issues`);
29
+ L.push(` 3. commit — the guard runs ${pc.bold("scan --staged --ci")} automatically`);
30
+ L.push("");
31
+ L.push(pc.dim(" Everything runs locally. SecureVibe never stores or transmits your secrets or API keys."));
32
+ L.push("");
33
+ return L.join("\n");
34
+ }
@@ -0,0 +1,62 @@
1
+ // packages/cli/src/ui/prompt.ts
2
+ /**
3
+ * Interactive approval prompt for `fix --apply`. Pure decision logic
4
+ * (decideApply, mapAnswer) is separated from IO (makeFixConfirm) so it is
5
+ * unit-tested without a real TTY. Prompts render to stderr so --json on stdout
6
+ * stays clean; the readline input is the user's terminal.
7
+ */
8
+ import readline from "node:readline";
9
+ /** True only when both stdin and stdout are real terminals. */
10
+ export function isInteractive() {
11
+ return !!process.stdout.isTTY && !!process.stdin.isTTY;
12
+ }
13
+ /**
14
+ * Decide how a `fix` invocation should behave. Truth table:
15
+ * - not --apply → preview only.
16
+ * - --apply --yes → write everything, no prompt.
17
+ * - --apply, --json or non-TTY, no --yes → refuse to write, show notice.
18
+ * - --apply, interactive, no --yes → write with per-change prompt.
19
+ */
20
+ export function decideApply(opts, interactive) {
21
+ if (!opts.apply)
22
+ return { apply: false, prompt: false, notice: false };
23
+ if (opts.yes)
24
+ return { apply: true, prompt: false, notice: false };
25
+ if (opts.json || !interactive)
26
+ return { apply: false, prompt: false, notice: true };
27
+ return { apply: true, prompt: true, notice: false };
28
+ }
29
+ /** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no". */
30
+ export function mapAnswer(raw, kind) {
31
+ const c = raw.trim().toLowerCase().charAt(0);
32
+ if (kind === "side-effects")
33
+ return c === "y" ? "yes" : "no";
34
+ if (c === "y")
35
+ return "yes";
36
+ if (c === "a")
37
+ return "all";
38
+ if (c === "q")
39
+ return "quit";
40
+ return "no";
41
+ }
42
+ /** Read one line. Streams are injectable so the readline round-trip is testable. */
43
+ export function askLine(prompt, input = process.stdin, output = process.stderr) {
44
+ return new Promise((resolve) => {
45
+ const rl = readline.createInterface({ input, output });
46
+ rl.question(prompt, (answer) => {
47
+ rl.close();
48
+ resolve(answer);
49
+ });
50
+ });
51
+ }
52
+ /** Build a confirm callback that renders each request and reads one line of input. */
53
+ export function makeFixConfirm(render, input = process.stdin, output = process.stderr) {
54
+ return async (req) => {
55
+ output.write(render(req) + "\n");
56
+ const prompt = req.kind === "patch"
57
+ ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit: "
58
+ : " Apply these supporting changes? [y]es / [n]o: ";
59
+ const raw = await askLine(prompt, input, output);
60
+ return mapAnswer(raw, req.kind);
61
+ };
62
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `protect` and `score` views. `protect` is the remediation-first lens (doc 05):
3
+ * it leads with the fix for every finding. `score` is the one-glance verdict.
4
+ */
5
+ import pc from "picocolors";
6
+ import { renderBanner } from "./banner.js";
7
+ const SEV_COLOR = {
8
+ critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
9
+ high: (s) => pc.red(pc.bold(s)),
10
+ medium: (s) => pc.yellow(s),
11
+ low: (s) => pc.blue(s),
12
+ info: (s) => pc.dim(s),
13
+ };
14
+ function readinessLine(r) {
15
+ switch (r) {
16
+ case "ship":
17
+ return pc.green(pc.bold("✓ SHIP — no blocking issues"));
18
+ case "warn":
19
+ return pc.yellow(pc.bold("⚠ SHIP WITH WARNINGS"));
20
+ case "block":
21
+ return pc.red(pc.bold("✗ BLOCK DEPLOY — fix blocking issues first"));
22
+ }
23
+ }
24
+ export function renderRemediations(result) {
25
+ const lines = [];
26
+ lines.push(renderBanner({ subtitle: "remediation plan" }));
27
+ lines.push("");
28
+ if (result.findings.length === 0) {
29
+ lines.push(pc.green(" ✓ Nothing to remediate."));
30
+ lines.push("");
31
+ return lines.join("\n");
32
+ }
33
+ // Group remediations by file (how a developer would apply them).
34
+ const byFile = new Map();
35
+ for (const f of result.findings) {
36
+ const arr = byFile.get(f.file) ?? [];
37
+ arr.push(f);
38
+ byFile.set(f.file, arr);
39
+ }
40
+ for (const [file, fileFindings] of byFile) {
41
+ lines.push(" " + pc.cyan(pc.bold(file)));
42
+ for (const f of fileFindings) {
43
+ const sev = SEV_COLOR[f.severity](` ${f.severity.toUpperCase()} `);
44
+ lines.push(` ${sev} ${pc.bold(f.title)} ${pc.dim(`(L${f.line})`)}`);
45
+ lines.push(` ${pc.green("→ fix:")} ${f.fix.replace(/\s+/g, " ").trim()}`);
46
+ }
47
+ lines.push("");
48
+ }
49
+ lines.push(pc.dim(" ─────────────────────────────────────────────────"));
50
+ lines.push(` ${readinessLine(result.score.readiness)}`);
51
+ lines.push(pc.dim(" Run `securevibe fix` to apply these remediations automatically (dry run by default) " +
52
+ "and re-scan to verify. `protect` is the read-only plan."));
53
+ lines.push("");
54
+ return lines.join("\n");
55
+ }
56
+ export function renderScoreOnly(result) {
57
+ const s = result.score;
58
+ const gc = s.grade === "A" || s.grade === "B" ? pc.green : s.grade === "C" ? pc.yellow : pc.red;
59
+ const lines = [];
60
+ lines.push(renderBanner({ subtitle: "security score" }));
61
+ lines.push("");
62
+ lines.push(` ${gc(pc.bold(` ${s.grade} `))} ${pc.bold(`${s.composite}/100`)} ${readinessLine(s.readiness)}`);
63
+ lines.push("");
64
+ lines.push(` app ${s.subScores.appSecurity} · ${pc.magenta(`ai ${s.subScores.aiSecurity}`)} · secrets ${s.subScores.dependencies} · deploy ${s.subScores.deployment}`);
65
+ lines.push("");
66
+ return lines.join("\n");
67
+ }
@@ -0,0 +1,62 @@
1
+ // packages/cli/src/ui/readiness.ts
2
+ /**
3
+ * Launch-readiness scorecard view + the one-line summary embedded in `scan`.
4
+ * Honest framing: passing the gates means these checks are satisfied, not that
5
+ * the app is proven secure.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ function verdictBadge(lr) {
10
+ if (lr.verdict === "ready")
11
+ return pc.bgGreen(pc.black(pc.bold(" LAUNCH READY ")));
12
+ if (lr.verdict === "warnings") {
13
+ return pc.yellow(pc.bold(`LAUNCH READINESS ! ready with ${lr.warnings} warning${lr.warnings === 1 ? "" : "s"}`));
14
+ }
15
+ return pc.bgRed(pc.white(pc.bold(` NOT READY — ${lr.blockers} blocker${lr.blockers === 1 ? "" : "s"} `)));
16
+ }
17
+ function gateLine(g) {
18
+ const sym = g.status === "pass" ? pc.green("✓")
19
+ : g.status === "blocker" ? pc.red("✕")
20
+ : g.status === "warning" ? pc.yellow("!")
21
+ : pc.dim("○");
22
+ const label = g.label.padEnd(30);
23
+ const detail = g.status === "pass" ? pc.dim("ok")
24
+ : g.status === "not-assessed" ? pc.dim(g.detail)
25
+ : g.status === "blocker" ? pc.red(g.detail)
26
+ : pc.yellow(g.detail);
27
+ return `${sym} ${label} ${detail}`;
28
+ }
29
+ export function renderScorecard(result) {
30
+ const lr = result.launchReadiness;
31
+ const L = [];
32
+ L.push(renderBanner({ subtitle: "launch readiness" }));
33
+ L.push(pc.dim(` ${result.root}`));
34
+ L.push("");
35
+ if (!lr) {
36
+ L.push(" Launch readiness is only evaluated on a full scan.");
37
+ L.push("");
38
+ return L.join("\n");
39
+ }
40
+ L.push(" " + verdictBadge(lr));
41
+ L.push("");
42
+ for (const g of lr.gates)
43
+ L.push(" " + gateLine(g));
44
+ L.push("");
45
+ L.push(pc.dim(" ─────────────────────────────────────────────────"));
46
+ L.push(" " + pc.bold("Next:") + pc.dim(" run ") + pc.cyan("securevibe fix") + pc.dim(" to auto-harden what's mechanical; the rest carry exact instructions."));
47
+ L.push(pc.dim(" Passing these gates means these checks are satisfied — not that the app is proven secure. Review the diffs and run your tests."));
48
+ L.push("");
49
+ return L.join("\n");
50
+ }
51
+ /** One line embedded in the scan report (empty when readiness was not evaluated). */
52
+ export function readinessSummaryLine(result) {
53
+ const lr = result.launchReadiness;
54
+ if (!lr)
55
+ return "";
56
+ if (lr.verdict === "ready")
57
+ return pc.green("Launch readiness ✓ ready");
58
+ if (lr.verdict === "warnings") {
59
+ return pc.yellow(`Launch readiness ! ready with ${lr.warnings} warning${lr.warnings === 1 ? "" : "s"}`) + pc.dim(" → securevibe ready");
60
+ }
61
+ return pc.red(`Launch readiness ✕ not ready (${lr.blockers} blocker${lr.blockers === 1 ? "" : "s"})`) + pc.dim(" → run `securevibe ready`");
62
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Terminal report — the "score reveal" signature moment (doc 17). Calm,
3
+ * prioritized, remediation-first. Colour via picocolors; degrades cleanly
4
+ * when colour is disabled.
5
+ */
6
+ import pc from "picocolors";
7
+ import { dependencyLine } from "./deps.js";
8
+ import { renderBanner } from "./banner.js";
9
+ import { readinessSummaryLine } from "./readiness.js";
10
+ const SEV_LABEL = {
11
+ critical: (s) => pc.bgRed(pc.white(pc.bold(` ${s} `))),
12
+ high: (s) => pc.red(pc.bold(s)),
13
+ medium: (s) => pc.yellow(pc.bold(s)),
14
+ low: (s) => pc.blue(s),
15
+ info: (s) => pc.dim(s),
16
+ };
17
+ function sevBadge(sev) {
18
+ const text = sev.toUpperCase().padEnd(8);
19
+ return SEV_LABEL[sev](text);
20
+ }
21
+ function gradeColor(grade) {
22
+ if (grade === "A" || grade === "B")
23
+ return pc.green;
24
+ if (grade === "C")
25
+ return pc.yellow;
26
+ return pc.red;
27
+ }
28
+ function readinessBadge(r) {
29
+ switch (r) {
30
+ case "ship":
31
+ return pc.green(pc.bold(" ✓ SHIP "));
32
+ case "warn":
33
+ return pc.yellow(pc.bold(" ⚠ SHIP WITH WARNINGS "));
34
+ case "block":
35
+ return pc.bgRed(pc.white(pc.bold(" ✗ BLOCK DEPLOY ")));
36
+ }
37
+ }
38
+ function bar(value, width = 24) {
39
+ const filled = Math.round((value / 100) * width);
40
+ const color = value >= 80 ? pc.green : value >= 60 ? pc.yellow : pc.red;
41
+ return color("█".repeat(filled)) + pc.dim("░".repeat(width - filled));
42
+ }
43
+ function gradeFor(n) {
44
+ if (n >= 90)
45
+ return "A";
46
+ if (n >= 80)
47
+ return "B";
48
+ if (n >= 70)
49
+ return "C";
50
+ if (n >= 55)
51
+ return "D";
52
+ if (n >= 40)
53
+ return "E";
54
+ return "F";
55
+ }
56
+ export function renderReport(result, opts = {}) {
57
+ const lines = [];
58
+ const { score, inventory, findings } = result;
59
+ // Dependency findings are surfaced on the Dependencies line, not in the code
60
+ // findings list / histogram — so the code finding count stays the code count.
61
+ const codeFindings = findings.filter((f) => f.category !== "dependency" && f.category !== "readiness");
62
+ // In AI-focus mode the composite is meaningless (other categories weren't
63
+ // scanned), so headline the AI sub-score instead.
64
+ const headlineValue = opts.aiFocus ? score.subScores.aiSecurity : score.composite;
65
+ const headlineGrade = opts.aiFocus ? gradeFor(headlineValue) : score.grade;
66
+ const scoreLabel = opts.aiFocus ? "AI Security Score" : "Security Score";
67
+ lines.push(renderBanner({ subtitle: opts.aiFocus ? "AI application security audit" : "security scan" }));
68
+ lines.push(pc.dim(` ${result.root}`));
69
+ lines.push("");
70
+ // Inventory
71
+ const inv = [
72
+ inventory.languages.length ? `langs: ${inventory.languages.join(", ")}` : "",
73
+ inventory.frameworks.length ? `frameworks: ${inventory.frameworks.join(", ")}` : "",
74
+ inventory.aiIntegrations.length ? pc.magenta(`AI: ${inventory.aiIntegrations.join(", ")}`) : "",
75
+ inventory.deployTargets.length ? `deploy: ${inventory.deployTargets.join(", ")}` : "",
76
+ ].filter(Boolean);
77
+ for (const l of inv)
78
+ lines.push(" " + pc.dim("•") + " " + l);
79
+ lines.push(pc.dim(` ${result.filesScanned} files analyzed in ${result.durationMs}ms`));
80
+ lines.push("");
81
+ // Score block
82
+ const gc = gradeColor(headlineGrade);
83
+ lines.push(pc.dim(` ╭─ ${scoreLabel} ${"─".repeat(Math.max(0, 32 - scoreLabel.length))}`));
84
+ lines.push(" │ " +
85
+ gc(pc.bold(` ${headlineGrade} `)) +
86
+ " " +
87
+ pc.bold(`${headlineValue}/100`) +
88
+ " " +
89
+ readinessBadge(score.readiness));
90
+ lines.push(" │");
91
+ if (opts.aiFocus) {
92
+ lines.push(` │ ${pc.magenta("AI Security")} ${bar(score.subScores.aiSecurity)} ${score.subScores.aiSecurity}`);
93
+ }
94
+ else {
95
+ lines.push(` │ App Security ${bar(score.subScores.appSecurity)} ${score.subScores.appSecurity}`);
96
+ lines.push(` │ ${pc.magenta("AI Security")} ${bar(score.subScores.aiSecurity)} ${score.subScores.aiSecurity}`);
97
+ lines.push(` │ Secrets ${bar(score.subScores.dependencies)} ${score.subScores.dependencies}`);
98
+ lines.push(` │ Deployment ${bar(score.subScores.deployment)} ${score.subScores.deployment}`);
99
+ }
100
+ const depLine = dependencyLine(result);
101
+ if (depLine) {
102
+ lines.push(" │");
103
+ lines.push(` │ Dependencies ${depLine}`);
104
+ }
105
+ lines.push(pc.dim(" ╰────────────────────────────────────────────────"));
106
+ lines.push("");
107
+ const readyLine = readinessSummaryLine(result);
108
+ if (readyLine) {
109
+ lines.push(" " + readyLine);
110
+ lines.push("");
111
+ }
112
+ // Summary counts
113
+ const counts = countBySeverity(codeFindings);
114
+ const summary = ["critical", "high", "medium", "low"]
115
+ .map((s) => `${sevBadge(s).trimEnd()} ${counts[s]}`)
116
+ .join(" ");
117
+ lines.push(" " + summary);
118
+ lines.push("");
119
+ if (codeFindings.length === 0) {
120
+ lines.push(pc.green(" ✓ No findings. Looking good."));
121
+ lines.push("");
122
+ return lines.join("\n");
123
+ }
124
+ // Findings (top N)
125
+ const TOP = 15;
126
+ lines.push(pc.bold(` Findings`) + pc.dim(` (showing top ${Math.min(TOP, codeFindings.length)} of ${codeFindings.length}, highest risk first)`));
127
+ lines.push("");
128
+ for (const f of codeFindings.slice(0, TOP)) {
129
+ lines.push(...renderFinding(f));
130
+ }
131
+ if (codeFindings.length > TOP) {
132
+ lines.push(pc.dim(` …and ${codeFindings.length - TOP} more. Use --json for the full list.`));
133
+ lines.push("");
134
+ }
135
+ // Footer / next steps
136
+ lines.push(pc.dim(" ─────────────────────────────────────────────────"));
137
+ lines.push(" " + pc.bold("Next:") + pc.dim(" every finding above carries a fix. ") + "Run " + pc.cyan("securevibe protect") + pc.dim(" to review remediations,"));
138
+ lines.push(" " + pc.dim("or ") + pc.cyan("securevibe ai-audit") + pc.dim(" to focus on the AI-agent attack surface."));
139
+ lines.push("");
140
+ return lines.join("\n");
141
+ }
142
+ function renderFinding(f) {
143
+ const out = [];
144
+ const tag = f.category === "ai" ? pc.magenta(" [AI]") : "";
145
+ out.push(` ${sevBadge(f.severity)} ${pc.bold(f.title)}${tag}`);
146
+ out.push(` ${pc.dim("↳")} ${pc.cyan(`${f.file}:${f.line}`)} ${pc.dim(metaLine(f))}`);
147
+ if (f.evidence)
148
+ out.push(` ${pc.dim("│")} ${pc.dim(truncate(f.evidence, 90))}`);
149
+ out.push(` ${pc.dim("why:")} ${truncate(f.why, 140)}`);
150
+ if (f.taint && f.taint.length)
151
+ out.push(` ${pc.dim("flow:")} ${pc.dim(f.taint.join(" "))}`);
152
+ out.push(` ${pc.green("fix:")} ${truncate(f.fix, 160)}`);
153
+ out.push("");
154
+ return out;
155
+ }
156
+ function metaLine(f) {
157
+ const parts = [f.cwe, f.owasp, f.mitre, `conf ${(f.confidence * 100) | 0}%`].filter(Boolean);
158
+ return parts.join(" · ");
159
+ }
160
+ function countBySeverity(findings) {
161
+ const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
162
+ for (const f of findings)
163
+ c[f.severity]++;
164
+ return c;
165
+ }
166
+ function truncate(s, n) {
167
+ const clean = s.replace(/\s+/g, " ").trim();
168
+ return clean.length > n ? clean.slice(0, n - 1) + "…" : clean;
169
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Free beta daily usage metering. A local counter in ~/.securevibe/usage.json:
3
+ * one use per analysis command, reset at local midnight. Soft enforcement by
4
+ * design — there is no account system yet — so this establishes the metering
5
+ * UX honestly without pretending to be tamper-proof. The --staged commit guard
6
+ * is never metered: a quota must not block someone's commit.
7
+ */
8
+ import { promises as fs } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ export const DAILY_LIMIT = 50;
12
+ export function usageFilePath() {
13
+ return path.join(os.homedir(), ".securevibe", "usage.json");
14
+ }
15
+ /** Local calendar day (not UTC) so the reset matches the user's midnight. */
16
+ export function localDay(now = new Date()) {
17
+ const y = now.getFullYear();
18
+ const m = String(now.getMonth() + 1).padStart(2, "0");
19
+ const d = String(now.getDate()).padStart(2, "0");
20
+ return `${y}-${m}-${d}`;
21
+ }
22
+ /** Pure state transition: same day increments, a new day resets, limit blocks. */
23
+ export function nextUsage(state, today, limit = DAILY_LIMIT) {
24
+ const base = state && state.date === today ? state : { date: today, count: 0 };
25
+ if (base.count >= limit) {
26
+ return { state: base, result: { allowed: false, used: base.count, limit } };
27
+ }
28
+ const next = { date: today, count: base.count + 1 };
29
+ return { state: next, result: { allowed: true, used: next.count, limit } };
30
+ }
31
+ async function readState(file) {
32
+ try {
33
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
34
+ if (typeof raw?.date === "string" && typeof raw?.count === "number" && raw.count >= 0) {
35
+ return { date: raw.date, count: Math.floor(raw.count) };
36
+ }
37
+ }
38
+ catch {
39
+ /* missing or corrupt → treated as a fresh day */
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Record one use against today's quota. Returns whether the command may run.
45
+ * Metering failures (unwritable home, etc.) never break the tool: on any IO
46
+ * error the use is allowed.
47
+ */
48
+ export async function recordUse(file = usageFilePath(), now = new Date()) {
49
+ try {
50
+ const { state, result } = nextUsage(await readState(file), localDay(now));
51
+ if (result.allowed) {
52
+ await fs.mkdir(path.dirname(file), { recursive: true });
53
+ await fs.writeFile(file, JSON.stringify(state) + "\n", "utf8");
54
+ }
55
+ return result;
56
+ }
57
+ catch {
58
+ return { allowed: true, used: 0, limit: DAILY_LIMIT };
59
+ }
60
+ }
61
+ /** The message shown when the daily quota is exhausted. */
62
+ export function limitMessage(limit = DAILY_LIMIT) {
63
+ return [
64
+ ` Daily free beta limit reached (${limit} analyses today).`,
65
+ " It resets at midnight. The commit guard (--staged) is never limited.",
66
+ " Paid plans with higher limits are coming during the beta.",
67
+ ].join("\n");
68
+ }