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.
- package/dist/engine/detectors/classic.js +83 -0
- package/dist/engine/readiness/scorecard.js +1 -1
- package/dist/engine/scan.js +7 -1
- package/dist/index.js +17 -23
- package/dist/ui/readiness.js +6 -1
- package/dist/ui/report.js +9 -2
- package/dist/ui/usage.js +12 -0
- package/package.json +1 -1
|
@@ -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" },
|
package/dist/engine/scan.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
});
|
package/dist/ui/readiness.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
|
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
|
|
138
|
-
lines.push(" " + pc.dim("
|
|
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
|
}
|
package/dist/ui/usage.js
ADDED
|
@@ -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