securevibe 0.1.0 → 0.1.2

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.
@@ -130,6 +130,87 @@ const codeExecution = (ctx) => {
130
130
  });
131
131
  return out;
132
132
  };
133
+ /**
134
+ * Path traversal: untrusted input reaches a filesystem path sink. Taint-gated
135
+ * (a static path is never flagged) and skipped when the argument is visibly
136
+ * sanitized (path.basename / secure_filename).
137
+ */
138
+ const FS_SINKS = new Set([
139
+ "readFile", "readFileSync", "writeFile", "writeFileSync", "appendFile",
140
+ "appendFileSync", "createReadStream", "createWriteStream", "unlink",
141
+ "unlinkSync", "rm", "rmSync", "readdir", "readdirSync", "sendFile",
142
+ ]);
143
+ const PY_FS_CALLEES = /^(open|os\.remove|os\.unlink|os\.rmdir|send_file)$/;
144
+ const PATH_SANITIZED = /\b(basename|secure_filename)\s*\(/;
145
+ const pathTraversal = (ctx) => {
146
+ const out = [];
147
+ walk(ctx.root, (n) => {
148
+ if (!isCall(n))
149
+ return;
150
+ const callee = calleeText(n) ?? "";
151
+ const last = calleeLastSegment(n) ?? "";
152
+ const isJsSink = ctx.file.lang !== "python" && FS_SINKS.has(last);
153
+ const isPySink = ctx.file.lang === "python" && PY_FS_CALLEES.test(callee);
154
+ if (!isJsSink && !isPySink)
155
+ return;
156
+ const argsNode = n.childForFieldName("arguments");
157
+ const firstArg = argsNode?.namedChild(0)?.text ?? "";
158
+ if (!firstArg || PATH_SANITIZED.test(firstArg))
159
+ return;
160
+ if (!isTainted(firstArg, ctx.taint) && !isUntrustedStringBuild(firstArg, ctx.taint))
161
+ return;
162
+ out.push(mkFinding(ctx, {
163
+ node: n,
164
+ detector: "path-traversal",
165
+ title: "Path traversal: untrusted input reaches a filesystem path",
166
+ category: "classic",
167
+ severity: "high",
168
+ confidence: 0.85,
169
+ reachable: true,
170
+ cwe: "CWE-22",
171
+ owasp: "A01:2021 Broken Access Control",
172
+ why: "A request-controlled value is used as a filesystem path. With ../ sequences an attacker can read or write files outside the intended directory (source code, .env, credentials).",
173
+ fix: "Never pass user input into a path directly. Resolve against a fixed base directory and verify the result stays inside it, or map ids to filenames and use path.basename / secure_filename on any user-supplied segment.",
174
+ taint: ["request input", "→ file path", `→ ${callee}()`],
175
+ }));
176
+ });
177
+ return out;
178
+ };
179
+ /** Server-side request forgery: untrusted input becomes the URL of a server-side request. */
180
+ const JS_HTTP_CALLEE = /^(fetch|axios(\.(get|post|put|patch|delete|head|request))?|got(\.(get|post|put|patch|delete|head))?|https?\.(get|request))$/;
181
+ const PY_HTTP_CALLEE = /^(requests\.(get|post|put|patch|delete|head)|httpx\.(get|post|put|patch|delete|head)|urllib\.request\.urlopen|urlopen)$/;
182
+ const ssrf = (ctx) => {
183
+ const out = [];
184
+ walk(ctx.root, (n) => {
185
+ if (!isCall(n))
186
+ return;
187
+ const callee = calleeText(n) ?? "";
188
+ const re = ctx.file.lang === "python" ? PY_HTTP_CALLEE : JS_HTTP_CALLEE;
189
+ if (!re.test(callee))
190
+ return;
191
+ const argsNode = n.childForFieldName("arguments");
192
+ const firstArg = argsNode?.namedChild(0)?.text ?? "";
193
+ if (!firstArg)
194
+ return;
195
+ if (!isTainted(firstArg, ctx.taint) && !isUntrustedStringBuild(firstArg, ctx.taint))
196
+ return;
197
+ out.push(mkFinding(ctx, {
198
+ node: n,
199
+ detector: "ssrf",
200
+ title: "SSRF: untrusted input controls a server-side request URL",
201
+ category: "classic",
202
+ severity: "high",
203
+ confidence: 0.75,
204
+ reachable: true,
205
+ cwe: "CWE-918",
206
+ owasp: "A10:2021 Server-Side Request Forgery",
207
+ why: "A request-controlled value is used as the URL of a server-side HTTP request. An attacker can make your server call internal services, cloud metadata endpoints, or arbitrary hosts.",
208
+ fix: "Validate the URL against an allowlist of hosts/schemes before requesting it. Never fetch a raw user-supplied URL; block private IP ranges and metadata endpoints.",
209
+ taint: ["request input", "→ URL", `→ ${callee}()`],
210
+ }));
211
+ });
212
+ return out;
213
+ };
133
214
  /** Cross-site scripting (XSS). */
134
215
  const xss = (ctx) => {
135
216
  const out = [];
@@ -278,6 +359,8 @@ function lineNumberOf(code, re) {
278
359
  export const classicDetectors = [
279
360
  sqlInjection,
280
361
  codeExecution,
362
+ pathTraversal,
363
+ ssrf,
281
364
  xss,
282
365
  brokenAccessControl,
283
366
  insecureConfig,
@@ -1,7 +1,7 @@
1
1
  const GATES = [
2
2
  { id: "secrets", label: "Secrets managed", detectors: ["hardcoded-secret"], crit: "blocker" },
3
3
  { id: "deps", label: "Dependencies current", detectors: ["vulnerable-dependency"], crit: "critical-only" },
4
- { id: "injection", label: "Injection safe (SQLi/RCE)", detectors: ["sql-injection", "code-execution"], crit: "blocker" },
4
+ { id: "injection", label: "Injection safe (SQLi/RCE/SSRF)", detectors: ["sql-injection", "code-execution", "path-traversal", "ssrf"], crit: "blocker" },
5
5
  { id: "authz", label: "Access control", detectors: ["broken-access-control"], crit: "blocker" },
6
6
  { id: "ai", label: "AI agent safety", detectors: ["ai-tool-hijack", "ai-missing-firewall", "ai-prompt-injection", "ai-overprivileged-tool"], crit: "high-blocks" },
7
7
  { id: "transport", label: "Transport config (CORS/JWT)", detectors: ["cors-misconfig", "weak-jwt"], crit: "warning" },
@@ -98,6 +98,7 @@ export async function scan(root, options = {}) {
98
98
  const readinessFindings = [];
99
99
  let headersSeen = false;
100
100
  let headerAnchor;
101
+ let firstFile;
101
102
  progress(`Analyzing ${files.length} files…`);
102
103
  for (const abs of files) {
103
104
  const rel = path.relative(absRoot, abs).split(path.sep).join("/");
@@ -115,6 +116,8 @@ export async function scan(root, options = {}) {
115
116
  parsedCount++;
116
117
  findings.push(...(await detectFile(rel, code, { aiOnly: options.aiOnly, depDb })));
117
118
  if (fullScan) {
119
+ if (!firstFile)
120
+ firstFile = rel;
118
121
  readinessFindings.push(...checkDebugExposure(rel, code));
119
122
  if (!headersSeen && fileHasSecurityHeader(code))
120
123
  headersSeen = true;
@@ -124,7 +127,10 @@ export async function scan(root, options = {}) {
124
127
  }
125
128
  progress("Scoring…");
126
129
  if (fullScan && isWebApp(inventory) && !(headersSeen || inventory.dependencies.includes("helmet"))) {
127
- readinessFindings.push(...checkSecurityHeaders(headerAnchor ?? "package.json"));
130
+ // Anchor to a real config file when we found one; otherwise to any file we
131
+ // actually scanned (so `fix` can read it) rather than a name that may not
132
+ // exist in this project (e.g. package.json in a Python-only repo).
133
+ readinessFindings.push(...checkSecurityHeaders(headerAnchor ?? firstFile ?? "package.json"));
128
134
  }
129
135
  findings.push(...readinessFindings);
130
136
  const deduped = dedupe(findings);
package/dist/index.js CHANGED
@@ -10,11 +10,12 @@
10
10
  import { Command } from "commander";
11
11
  import { scan } from "./engine/scan.js";
12
12
  import { toJson, toSarif } from "./ui/emit.js";
13
+ import { VERSION } from "./version.js";
13
14
  const program = new Command();
14
15
  program
15
16
  .name("securevibe")
16
17
  .description("Autonomous AI security engineer for AI-generated applications")
17
- .version("0.1.0");
18
+ .version(VERSION);
18
19
  function addCommon(cmd) {
19
20
  return cmd
20
21
  .option("--json", "output machine-readable JSON")
@@ -26,22 +27,25 @@ function addCommon(cmd) {
26
27
  /**
27
28
  * Free beta metering: one use per analysis command against the daily quota.
28
29
  * The --staged commit guard is exempt — a quota must never block a commit.
30
+ * Returns the usage state so callers can show it, or undefined when this run
31
+ * wasn't metered (--staged).
29
32
  */
30
33
  async function meter(opts = {}) {
31
34
  if (opts.staged)
32
- return;
35
+ return undefined;
33
36
  const { recordUse, limitMessage } = await import("./usage.js");
34
37
  const r = await recordUse();
35
38
  if (!r.allowed) {
36
39
  process.stderr.write(limitMessage() + "\n");
37
40
  process.exit(1);
38
41
  }
42
+ return r;
39
43
  }
40
44
  /** Run a scan with stderr progress; honour --json/--sarif/--no-color. */
41
45
  async function runScan(pathArg, opts, scanOpts = {}) {
42
46
  if (opts.color === false)
43
47
  process.env.NO_COLOR = "1";
44
- await meter(opts);
48
+ const usage = await meter(opts);
45
49
  const target = pathArg ?? ".";
46
50
  const quiet = opts.json || opts.sarif;
47
51
  // --staged: analyze only what's about to be committed (the pre-commit guard).
@@ -62,7 +66,7 @@ async function runScan(pathArg, opts, scanOpts = {}) {
62
66
  ...scanOpts,
63
67
  onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
64
68
  });
65
- return result;
69
+ return { result, usage };
66
70
  }
67
71
  /** Emit machine output if requested; returns true if it handled output. */
68
72
  function emitMachine(result, opts) {
@@ -76,6 +80,18 @@ function emitMachine(result, opts) {
76
80
  }
77
81
  return false;
78
82
  }
83
+ /**
84
+ * Best-effort "a newer version exists" nudge on human-readable output only.
85
+ * Cached for a day, never blocks, never throws — see update-check.ts.
86
+ */
87
+ async function maybeNotifyUpdate(opts) {
88
+ if (opts.json || opts.sarif || opts.staged)
89
+ return;
90
+ const { checkForUpdate, updateNotice } = await import("./update-check.js");
91
+ const latest = await checkForUpdate(VERSION);
92
+ if (latest)
93
+ process.stderr.write("\n" + updateNotice(VERSION, latest) + "\n");
94
+ }
79
95
  /** Exit code policy for CI gating (doc 08/12). */
80
96
  function ciExit(result, opts) {
81
97
  if (!opts.ci)
@@ -87,24 +103,16 @@ function ciExit(result, opts) {
87
103
  process.exit(1);
88
104
  process.exit(0);
89
105
  }
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
106
  addCommon(program
99
107
  .command("scan", { isDefault: true })
100
108
  .description("Scan a repository for vulnerabilities and AI-agent risks")
101
109
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
102
- const result = await runScan(pathArg, opts);
110
+ const { result, usage } = await runScan(pathArg, opts);
103
111
  if (!emitMachine(result, opts)) {
104
112
  const { renderReport } = await import("./ui/report.js");
105
- process.stdout.write(renderReport(result) + "\n");
106
- fixHint(result, opts);
113
+ process.stdout.write(renderReport(result, { usage }) + "\n");
107
114
  }
115
+ await maybeNotifyUpdate(opts);
108
116
  ciExit(result, opts);
109
117
  });
110
118
  program
@@ -151,71 +159,78 @@ program
151
159
  if (decision.notice) {
152
160
  process.stderr.write(" Not applied: no interactive terminal. Re-run with --yes to apply non-interactively.\n");
153
161
  }
162
+ await maybeNotifyUpdate({ json: opts.json });
154
163
  });
155
164
  addCommon(program
156
165
  .command("ai-audit")
157
166
  .description("Focus on the AI-application / agent attack surface (doc 04)")
158
167
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
159
- const result = await runScan(pathArg, opts, { aiOnly: true });
168
+ const { result, usage } = await runScan(pathArg, opts, { aiOnly: true });
160
169
  if (!emitMachine(result, opts)) {
161
170
  const { renderReport } = await import("./ui/report.js");
162
- process.stdout.write(renderReport(result, { aiFocus: true }) + "\n");
171
+ process.stdout.write(renderReport(result, { aiFocus: true, usage }) + "\n");
163
172
  }
173
+ await maybeNotifyUpdate(opts);
164
174
  ciExit(result, opts);
165
175
  });
166
176
  addCommon(program
167
177
  .command("protect")
168
178
  .description("Remediation-first view: the fixes for every finding")
169
179
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
170
- const result = await runScan(pathArg, opts);
180
+ const { result, usage } = await runScan(pathArg, opts);
171
181
  if (!emitMachine(result, opts)) {
172
182
  const { renderRemediations } = await import("./ui/protect.js");
173
- process.stdout.write(renderRemediations(result) + "\n");
183
+ process.stdout.write(renderRemediations(result, usage) + "\n");
174
184
  }
185
+ await maybeNotifyUpdate(opts);
175
186
  ciExit(result, opts);
176
187
  });
177
188
  addCommon(program
178
189
  .command("attack-map")
179
190
  .description("Show exploit paths from reachable findings (doc 06)")
180
191
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
181
- const result = await runScan(pathArg, opts);
192
+ const { result, usage } = await runScan(pathArg, opts);
182
193
  if (!emitMachine(result, opts)) {
183
194
  const { renderAttackMap } = await import("./ui/attackmap.js");
184
- process.stdout.write(renderAttackMap(result) + "\n");
195
+ process.stdout.write(renderAttackMap(result, usage) + "\n");
185
196
  }
197
+ await maybeNotifyUpdate(opts);
186
198
  ciExit(result, opts);
187
199
  });
188
200
  addCommon(program
189
201
  .command("score")
190
202
  .description("Print the security score and deployment-readiness verdict")
191
203
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
192
- const result = await runScan(pathArg, opts);
204
+ const { result, usage } = await runScan(pathArg, opts);
193
205
  if (!emitMachine(result, opts)) {
194
206
  const { renderScoreOnly } = await import("./ui/protect.js");
195
- process.stdout.write(renderScoreOnly(result) + "\n");
207
+ process.stdout.write(renderScoreOnly(result, usage) + "\n");
196
208
  }
209
+ await maybeNotifyUpdate(opts);
197
210
  ciExit(result, opts);
198
211
  });
199
212
  addCommon(program
200
213
  .command("deps")
201
214
  .description("Audit dependencies for known CVEs (SCA, offline against the local OSV db)")
202
215
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
203
- const result = await runScan(pathArg, opts);
216
+ const { result, usage } = await runScan(pathArg, opts);
204
217
  if (!emitMachine(result, opts)) {
205
218
  const { renderDeps } = await import("./ui/deps.js");
206
- process.stdout.write(renderDeps(result) + "\n");
219
+ process.stdout.write(renderDeps(result, usage) + "\n");
207
220
  }
221
+ await maybeNotifyUpdate(opts);
208
222
  ciExit(result, opts);
209
223
  });
210
224
  addCommon(program
211
225
  .command("ready")
212
226
  .description("Launch-readiness scorecard: pass/fail gates + a go/no-go verdict")
213
227
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
214
- const result = await runScan(pathArg, opts);
228
+ const { result, usage } = await runScan(pathArg, opts);
215
229
  if (!emitMachine(result, opts)) {
216
230
  const { renderScorecard } = await import("./ui/readiness.js");
217
- process.stdout.write(renderScorecard(result) + "\n");
231
+ process.stdout.write(renderScorecard(result, usage) + "\n");
218
232
  }
233
+ await maybeNotifyUpdate(opts);
219
234
  ciExit(result, opts);
220
235
  });
221
236
  program
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import pc from "picocolors";
8
8
  import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
9
10
  function impactOf(f) {
10
11
  switch (f.detector) {
11
12
  case "sql-injection":
@@ -26,10 +27,14 @@ function impactOf(f) {
26
27
  return "compromise of " + f.file;
27
28
  }
28
29
  }
29
- export function renderAttackMap(result) {
30
+ export function renderAttackMap(result, usage) {
30
31
  const lines = [];
31
32
  lines.push(renderBanner({ subtitle: "attack map · exploit paths" }));
32
33
  lines.push("");
34
+ if (usage) {
35
+ lines.push(renderUsageLine(usage));
36
+ lines.push("");
37
+ }
33
38
  const chains = result.findings.filter((f) => f.reachable && (f.severity === "critical" || f.severity === "high"));
34
39
  if (chains.length === 0) {
35
40
  lines.push(pc.green(" ✓ No reachable critical/high exploit paths found."));
package/dist/ui/deps.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import pc from "picocolors";
8
8
  import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
9
10
  const SEV_COLOR = {
10
11
  critical: (s) => pc.bgRed(pc.white(pc.bold(` ${s} `))),
11
12
  high: (s) => pc.red(pc.bold(s)),
@@ -25,12 +26,16 @@ export function dependencyLine(result) {
25
26
  .map((s) => `${d.bySeverity[s]} ${s}`);
26
27
  return `${d.total} known ${d.total === 1 ? "CVE" : "CVEs"} (${parts.join(", ")}) · ${date}`;
27
28
  }
28
- export function renderDeps(result) {
29
+ export function renderDeps(result, usage) {
29
30
  const lines = [];
30
31
  const deps = result.findings.filter((f) => f.detector === "vulnerable-dependency");
31
32
  lines.push(renderBanner({ subtitle: "dependency audit (SCA)" }));
32
33
  lines.push(pc.dim(` ${result.root}`));
33
34
  lines.push("");
35
+ if (usage) {
36
+ lines.push(renderUsageLine(usage));
37
+ lines.push("");
38
+ }
34
39
  lines.push(" " + (deps.length === 0 ? pc.green("✓ ") : "") + dependencyLine(result));
35
40
  lines.push("");
36
41
  for (const f of deps)
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import pc from "picocolors";
6
6
  import { renderBanner } from "./banner.js";
7
+ import { renderUsageLine } from "./usage.js";
7
8
  const SEV_COLOR = {
8
9
  critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
9
10
  high: (s) => pc.red(pc.bold(s)),
@@ -21,10 +22,14 @@ function readinessLine(r) {
21
22
  return pc.red(pc.bold("✗ BLOCK DEPLOY — fix blocking issues first"));
22
23
  }
23
24
  }
24
- export function renderRemediations(result) {
25
+ export function renderRemediations(result, usage) {
25
26
  const lines = [];
26
27
  lines.push(renderBanner({ subtitle: "remediation plan" }));
27
28
  lines.push("");
29
+ if (usage) {
30
+ lines.push(renderUsageLine(usage));
31
+ lines.push("");
32
+ }
28
33
  if (result.findings.length === 0) {
29
34
  lines.push(pc.green(" ✓ Nothing to remediate."));
30
35
  lines.push("");
@@ -53,12 +58,16 @@ export function renderRemediations(result) {
53
58
  lines.push("");
54
59
  return lines.join("\n");
55
60
  }
56
- export function renderScoreOnly(result) {
61
+ export function renderScoreOnly(result, usage) {
57
62
  const s = result.score;
58
63
  const gc = s.grade === "A" || s.grade === "B" ? pc.green : s.grade === "C" ? pc.yellow : pc.red;
59
64
  const lines = [];
60
65
  lines.push(renderBanner({ subtitle: "security score" }));
61
66
  lines.push("");
67
+ if (usage) {
68
+ lines.push(renderUsageLine(usage));
69
+ lines.push("");
70
+ }
62
71
  lines.push(` ${gc(pc.bold(` ${s.grade} `))} ${pc.bold(`${s.composite}/100`)} ${readinessLine(s.readiness)}`);
63
72
  lines.push("");
64
73
  lines.push(` app ${s.subScores.appSecurity} · ${pc.magenta(`ai ${s.subScores.aiSecurity}`)} · secrets ${s.subScores.dependencies} · deploy ${s.subScores.deployment}`);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import pc from "picocolors";
8
8
  import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
9
10
  function verdictBadge(lr) {
10
11
  if (lr.verdict === "ready")
11
12
  return pc.bgGreen(pc.black(pc.bold(" LAUNCH READY ")));
@@ -26,12 +27,16 @@ function gateLine(g) {
26
27
  : pc.yellow(g.detail);
27
28
  return `${sym} ${label} ${detail}`;
28
29
  }
29
- export function renderScorecard(result) {
30
+ export function renderScorecard(result, usage) {
30
31
  const lr = result.launchReadiness;
31
32
  const L = [];
32
33
  L.push(renderBanner({ subtitle: "launch readiness" }));
33
34
  L.push(pc.dim(` ${result.root}`));
34
35
  L.push("");
36
+ if (usage) {
37
+ L.push(renderUsageLine(usage));
38
+ L.push("");
39
+ }
35
40
  if (!lr) {
36
41
  L.push(" Launch readiness is only evaluated on a full scan.");
37
42
  L.push("");
package/dist/ui/report.js CHANGED
@@ -7,6 +7,7 @@ import pc from "picocolors";
7
7
  import { dependencyLine } from "./deps.js";
8
8
  import { renderBanner } from "./banner.js";
9
9
  import { readinessSummaryLine } from "./readiness.js";
10
+ import { renderUsageLine } from "./usage.js";
10
11
  const SEV_LABEL = {
11
12
  critical: (s) => pc.bgRed(pc.white(pc.bold(` ${s} `))),
12
13
  high: (s) => pc.red(pc.bold(s)),
@@ -67,6 +68,10 @@ export function renderReport(result, opts = {}) {
67
68
  lines.push(renderBanner({ subtitle: opts.aiFocus ? "AI application security audit" : "security scan" }));
68
69
  lines.push(pc.dim(` ${result.root}`));
69
70
  lines.push("");
71
+ if (opts.usage) {
72
+ lines.push(renderUsageLine(opts.usage));
73
+ lines.push("");
74
+ }
70
75
  // Inventory
71
76
  const inv = [
72
77
  inventory.languages.length ? `langs: ${inventory.languages.join(", ")}` : "",
@@ -134,8 +139,10 @@ export function renderReport(result, opts = {}) {
134
139
  }
135
140
  // Footer / next steps
136
141
  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."));
142
+ lines.push(" " + pc.bold("Next steps"));
143
+ lines.push(" " + pc.dim("1.") + " " + pc.cyan("securevibe fix --apply") + pc.dim(" fix what's mechanical (asks before each change)"));
144
+ lines.push(" " + pc.dim("2.") + " " + pc.cyan("securevibe ready") + pc.dim(" — see what still blocks launch"));
145
+ lines.push(" " + pc.dim("3.") + " " + pc.cyan("securevibe init") + pc.dim(" — install the commit/CI gate so this doesn't regress"));
139
146
  lines.push("");
140
147
  return lines.join("\n");
141
148
  }
@@ -0,0 +1,12 @@
1
+ // packages/cli/src/ui/usage.ts
2
+ /** One-line free-beta daily usage bar, shown at the top of scan-style reports. */
3
+ import pc from "picocolors";
4
+ function bar(fraction, width = 20) {
5
+ const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width);
6
+ const color = fraction >= 0.9 ? pc.red : fraction >= 0.7 ? pc.yellow : pc.cyan;
7
+ return color("█".repeat(filled)) + pc.dim("░".repeat(width - filled));
8
+ }
9
+ export function renderUsageLine(usage) {
10
+ const fraction = usage.limit > 0 ? usage.used / usage.limit : 0;
11
+ return pc.dim(" Free beta usage today ") + bar(fraction) + pc.dim(` ${usage.used}/${usage.limit}`);
12
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Best-effort "a newer version is published" notice. There is no auto-update —
3
+ * users install a CLI globally and nothing tells them it went stale. This
4
+ * checks the npm registry at most once a day (cached in ~/.securevibe), never
5
+ * throws, and never slows a command down on a broken network (short timeout,
6
+ * fire-and-forget failure).
7
+ */
8
+ import { promises as fs } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ const REGISTRY_URL = "https://registry.npmjs.org/securevibe/latest";
12
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
13
+ const FETCH_TIMEOUT_MS = 1500;
14
+ export function cacheFilePath() {
15
+ return path.join(os.homedir(), ".securevibe", "update-check.json");
16
+ }
17
+ /** Simple major.minor.patch compare; true if `latest` is strictly newer than `current`. */
18
+ export function isNewer(current, latest) {
19
+ const a = current.split(".").map((n) => parseInt(n, 10) || 0);
20
+ const b = latest.split(".").map((n) => parseInt(n, 10) || 0);
21
+ for (let i = 0; i < 3; i++) {
22
+ const ai = a[i] ?? 0;
23
+ const bi = b[i] ?? 0;
24
+ if (bi > ai)
25
+ return true;
26
+ if (bi < ai)
27
+ return false;
28
+ }
29
+ return false;
30
+ }
31
+ async function readCache(file) {
32
+ try {
33
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
34
+ if (typeof raw?.checkedAt === "number" && typeof raw?.latest === "string")
35
+ return raw;
36
+ }
37
+ catch {
38
+ /* missing or corrupt → treated as no cache */
39
+ }
40
+ return null;
41
+ }
42
+ async function writeCache(file, state) {
43
+ try {
44
+ await fs.mkdir(path.dirname(file), { recursive: true });
45
+ await fs.writeFile(file, JSON.stringify(state) + "\n", "utf8");
46
+ }
47
+ catch {
48
+ /* best effort — a failed write must never break the command */
49
+ }
50
+ }
51
+ async function fetchLatest(fetchImpl) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
54
+ try {
55
+ const res = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
56
+ if (!res.ok)
57
+ return null;
58
+ const json = await res.json();
59
+ return typeof json?.version === "string" ? json.version : null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ /**
69
+ * Returns the newer published version if one exists, else null. Never
70
+ * throws — offline, a down registry, or a corrupt cache all resolve to null
71
+ * rather than interrupting the command.
72
+ */
73
+ export async function checkForUpdate(currentVersion, opts = {}) {
74
+ const file = opts.file ?? cacheFilePath();
75
+ const now = opts.now ?? Date.now();
76
+ const fetchImpl = opts.fetchImpl ?? fetch;
77
+ try {
78
+ const cached = await readCache(file);
79
+ let latest = null;
80
+ if (cached && now - cached.checkedAt < CACHE_TTL_MS) {
81
+ latest = cached.latest;
82
+ }
83
+ else {
84
+ latest = await fetchLatest(fetchImpl);
85
+ if (latest)
86
+ await writeCache(file, { checkedAt: now, latest });
87
+ else if (cached)
88
+ latest = cached.latest;
89
+ }
90
+ return latest && isNewer(currentVersion, latest) ? latest : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ export function updateNotice(currentVersion, latestVersion) {
97
+ return [
98
+ ` A new version of securevibe is available: ${latestVersion} (you have ${currentVersion}).`,
99
+ " Update: npm install -g securevibe",
100
+ ].join("\n");
101
+ }
@@ -0,0 +1,5 @@
1
+ // packages/cli/src/version.ts
2
+ // Single source of truth for the CLI's own version, so it can't drift from
3
+ // what commander reports and what the update-check compares against.
4
+ // Kept in sync with the "version" field in package.json by hand at release time.
5
+ export const VERSION = "0.1.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Autonomous AI security engineer for AI generated apps: scan, fix, verify, and gate every commit before you launch",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",