securevibe 0.1.0 → 0.1.1

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
@@ -14,7 +14,7 @@ const program = new Command();
14
14
  program
15
15
  .name("securevibe")
16
16
  .description("Autonomous AI security engineer for AI-generated applications")
17
- .version("0.1.0");
17
+ .version("0.1.1");
18
18
  function addCommon(cmd) {
19
19
  return cmd
20
20
  .option("--json", "output machine-readable JSON")
@@ -26,22 +26,25 @@ function addCommon(cmd) {
26
26
  /**
27
27
  * Free beta metering: one use per analysis command against the daily quota.
28
28
  * The --staged commit guard is exempt — a quota must never block a commit.
29
+ * Returns the usage state so callers can show it, or undefined when this run
30
+ * wasn't metered (--staged).
29
31
  */
30
32
  async function meter(opts = {}) {
31
33
  if (opts.staged)
32
- return;
34
+ return undefined;
33
35
  const { recordUse, limitMessage } = await import("./usage.js");
34
36
  const r = await recordUse();
35
37
  if (!r.allowed) {
36
38
  process.stderr.write(limitMessage() + "\n");
37
39
  process.exit(1);
38
40
  }
41
+ return r;
39
42
  }
40
43
  /** Run a scan with stderr progress; honour --json/--sarif/--no-color. */
41
44
  async function runScan(pathArg, opts, scanOpts = {}) {
42
45
  if (opts.color === false)
43
46
  process.env.NO_COLOR = "1";
44
- await meter(opts);
47
+ const usage = await meter(opts);
45
48
  const target = pathArg ?? ".";
46
49
  const quiet = opts.json || opts.sarif;
47
50
  // --staged: analyze only what's about to be committed (the pre-commit guard).
@@ -62,7 +65,7 @@ async function runScan(pathArg, opts, scanOpts = {}) {
62
65
  ...scanOpts,
63
66
  onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
64
67
  });
65
- return result;
68
+ return { result, usage };
66
69
  }
67
70
  /** Emit machine output if requested; returns true if it handled output. */
68
71
  function emitMachine(result, opts) {
@@ -87,23 +90,14 @@ function ciExit(result, opts) {
87
90
  process.exit(1);
88
91
  process.exit(0);
89
92
  }
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
93
  addCommon(program
99
94
  .command("scan", { isDefault: true })
100
95
  .description("Scan a repository for vulnerabilities and AI-agent risks")
101
96
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
102
- const result = await runScan(pathArg, opts);
97
+ const { result, usage } = await runScan(pathArg, opts);
103
98
  if (!emitMachine(result, opts)) {
104
99
  const { renderReport } = await import("./ui/report.js");
105
- process.stdout.write(renderReport(result) + "\n");
106
- fixHint(result, opts);
100
+ process.stdout.write(renderReport(result, { usage }) + "\n");
107
101
  }
108
102
  ciExit(result, opts);
109
103
  });
@@ -156,10 +150,10 @@ addCommon(program
156
150
  .command("ai-audit")
157
151
  .description("Focus on the AI-application / agent attack surface (doc 04)")
158
152
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
159
- const result = await runScan(pathArg, opts, { aiOnly: true });
153
+ const { result, usage } = await runScan(pathArg, opts, { aiOnly: true });
160
154
  if (!emitMachine(result, opts)) {
161
155
  const { renderReport } = await import("./ui/report.js");
162
- process.stdout.write(renderReport(result, { aiFocus: true }) + "\n");
156
+ process.stdout.write(renderReport(result, { aiFocus: true, usage }) + "\n");
163
157
  }
164
158
  ciExit(result, opts);
165
159
  });
@@ -167,7 +161,7 @@ addCommon(program
167
161
  .command("protect")
168
162
  .description("Remediation-first view: the fixes for every finding")
169
163
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
170
- const result = await runScan(pathArg, opts);
164
+ const { result } = await runScan(pathArg, opts);
171
165
  if (!emitMachine(result, opts)) {
172
166
  const { renderRemediations } = await import("./ui/protect.js");
173
167
  process.stdout.write(renderRemediations(result) + "\n");
@@ -178,7 +172,7 @@ addCommon(program
178
172
  .command("attack-map")
179
173
  .description("Show exploit paths from reachable findings (doc 06)")
180
174
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
181
- const result = await runScan(pathArg, opts);
175
+ const { result } = await runScan(pathArg, opts);
182
176
  if (!emitMachine(result, opts)) {
183
177
  const { renderAttackMap } = await import("./ui/attackmap.js");
184
178
  process.stdout.write(renderAttackMap(result) + "\n");
@@ -189,7 +183,7 @@ addCommon(program
189
183
  .command("score")
190
184
  .description("Print the security score and deployment-readiness verdict")
191
185
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
192
- const result = await runScan(pathArg, opts);
186
+ const { result } = await runScan(pathArg, opts);
193
187
  if (!emitMachine(result, opts)) {
194
188
  const { renderScoreOnly } = await import("./ui/protect.js");
195
189
  process.stdout.write(renderScoreOnly(result) + "\n");
@@ -200,7 +194,7 @@ addCommon(program
200
194
  .command("deps")
201
195
  .description("Audit dependencies for known CVEs (SCA, offline against the local OSV db)")
202
196
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
203
- const result = await runScan(pathArg, opts);
197
+ const { result } = await runScan(pathArg, opts);
204
198
  if (!emitMachine(result, opts)) {
205
199
  const { renderDeps } = await import("./ui/deps.js");
206
200
  process.stdout.write(renderDeps(result) + "\n");
@@ -211,10 +205,10 @@ addCommon(program
211
205
  .command("ready")
212
206
  .description("Launch-readiness scorecard: pass/fail gates + a go/no-go verdict")
213
207
  .argument("[path]", "path to the repository", ".")).action(async (pathArg, opts) => {
214
- const result = await runScan(pathArg, opts);
208
+ const { result, usage } = await runScan(pathArg, opts);
215
209
  if (!emitMachine(result, opts)) {
216
210
  const { renderScorecard } = await import("./ui/readiness.js");
217
- process.stdout.write(renderScorecard(result) + "\n");
211
+ process.stdout.write(renderScorecard(result, usage) + "\n");
218
212
  }
219
213
  ciExit(result, opts);
220
214
  });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securevibe",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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",