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.
- 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 +42 -27
- package/dist/ui/attackmap.js +6 -1
- package/dist/ui/deps.js +6 -1
- package/dist/ui/protect.js +11 -2
- package/dist/ui/readiness.js +6 -1
- package/dist/ui/report.js +9 -2
- package/dist/ui/usage.js +12 -0
- package/dist/update-check.js +101 -0
- package/dist/version.js +5 -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
|
@@ -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(
|
|
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
|
package/dist/ui/attackmap.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 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)
|
package/dist/ui/protect.js
CHANGED
|
@@ -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}`);
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/version.js
ADDED
|
@@ -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