dsh-vet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/dsh-vet.mjs +7 -0
- package/docs/calibration-v0.1.md +58 -0
- package/docs/dsh-vet-v1.md +159 -0
- package/docs/rules/dep.floating-range.md +26 -0
- package/docs/rules/dep.install-scripts.md +26 -0
- package/docs/rules/dep.typosquat-proximity.md +30 -0
- package/docs/rules/egress.outbound-endpoints.md +29 -0
- package/docs/rules/egress.secret-adjacent.md +31 -0
- package/docs/rules/obf.charcode-chain.md +24 -0
- package/docs/rules/obf.dynamic-require.md +27 -0
- package/docs/rules/obf.encoded-payload.md +29 -0
- package/docs/rules/obf.eval-detect.md +26 -0
- package/docs/rules/obf.unparseable.md +25 -0
- package/docs/rules/perm.network-client.md +27 -0
- package/docs/rules/perm.seam-mismatch.md +34 -0
- package/docs/rules/perm.subprocess-spawn.md +28 -0
- package/docs/rules/perm.undeclared-fs-write.md +32 -0
- package/docs/rules/perm.unreachable-files.md +26 -0
- package/lib/index.d.mts +297 -0
- package/lib/index.mjs +1741 -0
- package/package.json +64 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1741 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { parse } from "acorn";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { gunzipSync } from "node:zlib";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
//#region src/contract.ts
|
|
10
|
+
/**
|
|
11
|
+
* The `dsh-vet/v1` report contract: a machine-readable, implementation-
|
|
12
|
+
* agnostic audit report for DeepSeek Harness (DSH) plugins. Any scanner may
|
|
13
|
+
* emit it; any marketplace, CI job, or UI may consume it. See
|
|
14
|
+
* `docs/dsh-vet-v1.md` for the normative spec.
|
|
15
|
+
*
|
|
16
|
+
* Two design rules are encoded here, not just documented:
|
|
17
|
+
* - Findings are signals, not verdicts: a finding with `low` confidence never
|
|
18
|
+
* lowers a grade, and `info` severity never does either.
|
|
19
|
+
* - Reports are deterministic: findings are sorted, and the summary is always
|
|
20
|
+
* derived, never asserted by the emitter.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-vet/contract
|
|
23
|
+
*/
|
|
24
|
+
/** Literal `schema` value every dsh-vet/v1 report carries. */
|
|
25
|
+
const SCHEMA_ID = "dsh-vet/v1";
|
|
26
|
+
const SEVERITY_RANK = {
|
|
27
|
+
critical: 0,
|
|
28
|
+
high: 1,
|
|
29
|
+
medium: 2,
|
|
30
|
+
low: 3,
|
|
31
|
+
info: 4
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Rule ids are two or more dot-separated lowercase segments
|
|
35
|
+
* (`perm.broad-fs-write`, `acme.eval-detect`). Deliberately open-ended about
|
|
36
|
+
* segment count so vendor-prefixed rule sets work — a closed single-segment
|
|
37
|
+
* pattern is how the dsh-doctor contract initially broke third-party ids.
|
|
38
|
+
*/
|
|
39
|
+
const RULE_ID_PATTERN = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/;
|
|
40
|
+
/** True when a finding participates in grading: not `info`, not low-confidence. */
|
|
41
|
+
function isGraded(finding) {
|
|
42
|
+
return finding.severity !== "info" && finding.confidence !== "low";
|
|
43
|
+
}
|
|
44
|
+
/** Per-severity finding totals. */
|
|
45
|
+
function countFindings(findings) {
|
|
46
|
+
const counts = {
|
|
47
|
+
critical: 0,
|
|
48
|
+
high: 0,
|
|
49
|
+
medium: 0,
|
|
50
|
+
low: 0,
|
|
51
|
+
info: 0
|
|
52
|
+
};
|
|
53
|
+
for (const finding of findings) counts[finding.severity] += 1;
|
|
54
|
+
return counts;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Derive the grade from graded findings only: worst severity decides.
|
|
58
|
+
* `low`-confidence and `info` findings are reported but never lower a grade.
|
|
59
|
+
*/
|
|
60
|
+
function gradeFor(findings) {
|
|
61
|
+
let worst;
|
|
62
|
+
for (const finding of findings) {
|
|
63
|
+
if (!isGraded(finding)) continue;
|
|
64
|
+
if (worst === void 0 || SEVERITY_RANK[finding.severity] < SEVERITY_RANK[worst]) worst = finding.severity;
|
|
65
|
+
}
|
|
66
|
+
switch (worst) {
|
|
67
|
+
case void 0:
|
|
68
|
+
case "info": return "A";
|
|
69
|
+
case "low": return "B";
|
|
70
|
+
case "medium": return "C";
|
|
71
|
+
case "high": return "D";
|
|
72
|
+
case "critical": return "F";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a normalized report: validates rule ids, sorts findings
|
|
77
|
+
* deterministically (worst severity first, then id ascending), and derives
|
|
78
|
+
* the summary. Emitters must go through this instead of assembling a report
|
|
79
|
+
* by hand — it is what keeps two runs over the same artifact identical.
|
|
80
|
+
*/
|
|
81
|
+
function createReport(input) {
|
|
82
|
+
for (const finding of input.findings) if (!RULE_ID_PATTERN.test(finding.id)) throw new Error(`invalid dsh-vet/v1 rule id: ${JSON.stringify(finding.id)}`);
|
|
83
|
+
const findings = [...input.findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.id.localeCompare(b.id));
|
|
84
|
+
return {
|
|
85
|
+
schema: SCHEMA_ID,
|
|
86
|
+
target: input.target,
|
|
87
|
+
scanner: input.scanner,
|
|
88
|
+
summary: {
|
|
89
|
+
grade: gradeFor(findings),
|
|
90
|
+
counts: countFindings(findings)
|
|
91
|
+
},
|
|
92
|
+
findings
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/analyze.ts
|
|
97
|
+
/**
|
|
98
|
+
* Analysis engine (ROADMAP T2): parse shipped JS with acorn, build the static
|
|
99
|
+
* module graph, and classify the capability surface each file touches.
|
|
100
|
+
*
|
|
101
|
+
* Everything here is best-effort static analysis (ROADMAP D2): call arguments
|
|
102
|
+
* that depend on runtime values are recorded without interpretation, and the
|
|
103
|
+
* rules decide how much confidence that earns.
|
|
104
|
+
*/
|
|
105
|
+
const BUILTIN_BASE = {
|
|
106
|
+
fs: ["fs"],
|
|
107
|
+
"node:fs": ["fs"],
|
|
108
|
+
"fs/promises": ["fs"],
|
|
109
|
+
"node:fs/promises": ["fs"],
|
|
110
|
+
"node:child_process": ["shell"],
|
|
111
|
+
child_process: ["shell"],
|
|
112
|
+
net: ["net"],
|
|
113
|
+
"node:net": ["net"],
|
|
114
|
+
http: ["net"],
|
|
115
|
+
"node:http": ["net"],
|
|
116
|
+
https: ["net"],
|
|
117
|
+
"node:https": ["net"],
|
|
118
|
+
http2: ["net"],
|
|
119
|
+
"node:http2": ["net"],
|
|
120
|
+
tls: ["net"],
|
|
121
|
+
"node:tls": ["net"],
|
|
122
|
+
dgram: ["net"],
|
|
123
|
+
"node:dgram": ["net"],
|
|
124
|
+
dns: ["net"],
|
|
125
|
+
"node:dns": ["net"],
|
|
126
|
+
"node:worker_threads": ["workers"],
|
|
127
|
+
worker_threads: ["workers"],
|
|
128
|
+
"node:crypto": ["crypto"],
|
|
129
|
+
crypto: ["crypto"]
|
|
130
|
+
};
|
|
131
|
+
const FS_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
132
|
+
"writeFile",
|
|
133
|
+
"appendFile",
|
|
134
|
+
"rm",
|
|
135
|
+
"unlink",
|
|
136
|
+
"rmdir",
|
|
137
|
+
"truncate",
|
|
138
|
+
"rename",
|
|
139
|
+
"cp",
|
|
140
|
+
"createWriteStream",
|
|
141
|
+
"chmod",
|
|
142
|
+
"chown",
|
|
143
|
+
"writev"
|
|
144
|
+
]);
|
|
145
|
+
for (const m of [...FS_WRITE_METHODS]) FS_WRITE_METHODS.add(`${m}Sync`);
|
|
146
|
+
const FS_READ_METHODS = /* @__PURE__ */ new Set([
|
|
147
|
+
"readFile",
|
|
148
|
+
"readdir",
|
|
149
|
+
"createReadStream",
|
|
150
|
+
"stat",
|
|
151
|
+
"lstat",
|
|
152
|
+
"exists",
|
|
153
|
+
"open",
|
|
154
|
+
"access"
|
|
155
|
+
]);
|
|
156
|
+
for (const m of [...FS_READ_METHODS]) FS_READ_METHODS.add(`${m}Sync`);
|
|
157
|
+
const SHELL_METHODS = /* @__PURE__ */ new Set([
|
|
158
|
+
"spawn",
|
|
159
|
+
"exec",
|
|
160
|
+
"execFile",
|
|
161
|
+
"fork",
|
|
162
|
+
"spawnSync",
|
|
163
|
+
"execSync",
|
|
164
|
+
"execFileSync"
|
|
165
|
+
]);
|
|
166
|
+
const NET_METHODS = /* @__PURE__ */ new Set([
|
|
167
|
+
"request",
|
|
168
|
+
"get",
|
|
169
|
+
"connect",
|
|
170
|
+
"createConnection",
|
|
171
|
+
"createSocket",
|
|
172
|
+
"lookup",
|
|
173
|
+
"resolve"
|
|
174
|
+
]);
|
|
175
|
+
const HOMEDIR_METHODS = /* @__PURE__ */ new Set(["homedir", "userInfo"]);
|
|
176
|
+
const CREDENTIAL_PATH_RE = /(?:^|[/\\])(?:\.env[^/\\]*|\.dsh|credentials|auth\.json|token|secret|\.npmrc|\.netrc|\.aws|\.ssh|\.gitconfig)(?:$|[/\\])/i;
|
|
177
|
+
function toPosix(p) {
|
|
178
|
+
return p.split("\\").join("/");
|
|
179
|
+
}
|
|
180
|
+
function snippetAt(code, line) {
|
|
181
|
+
return (code.split("\n")[line - 1] ?? "").trim().slice(0, 120);
|
|
182
|
+
}
|
|
183
|
+
function listJsFiles(rootDir) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const walk = (dir) => {
|
|
186
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
187
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name.startsWith(".DS")) continue;
|
|
188
|
+
const abs = join(dir, entry.name);
|
|
189
|
+
if (entry.isDirectory()) walk(abs);
|
|
190
|
+
else if (/\.(?:js|mjs|cjs)$/.test(entry.name)) out.push(abs);
|
|
191
|
+
else if (!entry.name.includes(".")) {
|
|
192
|
+
const head = readFileSync(abs, "utf8").slice(0, 64);
|
|
193
|
+
if (/^#![^\n]*\bnode\b/.test(head)) out.push(abs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
walk(rootDir);
|
|
198
|
+
return out.sort();
|
|
199
|
+
}
|
|
200
|
+
function parseSource(path, code) {
|
|
201
|
+
const options = {
|
|
202
|
+
ecmaVersion: "latest",
|
|
203
|
+
locations: true
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
return {
|
|
207
|
+
ast: parse(code, {
|
|
208
|
+
...options,
|
|
209
|
+
sourceType: "module"
|
|
210
|
+
}),
|
|
211
|
+
sourceType: "module",
|
|
212
|
+
parseError: null
|
|
213
|
+
};
|
|
214
|
+
} catch (moduleError) {
|
|
215
|
+
try {
|
|
216
|
+
return {
|
|
217
|
+
ast: parse(code, {
|
|
218
|
+
...options,
|
|
219
|
+
sourceType: "script",
|
|
220
|
+
allowReturnOutsideFunction: true
|
|
221
|
+
}),
|
|
222
|
+
sourceType: "script",
|
|
223
|
+
parseError: null
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return {
|
|
227
|
+
ast: null,
|
|
228
|
+
sourceType: null,
|
|
229
|
+
parseError: moduleError.message
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function argLiterals(args) {
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const arg of args) {
|
|
237
|
+
if (arg.type === "Literal" && typeof arg.value === "string") out.push(arg.value);
|
|
238
|
+
if (arg.type === "TemplateLiteral" && arg.expressions.length === 0) out.push(arg.quasis[0]?.value.raw ?? "");
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
/** Best-effort static string evaluation for require()/import() arguments. */
|
|
243
|
+
function staticString(node) {
|
|
244
|
+
if (!node) return null;
|
|
245
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
246
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.raw ?? "";
|
|
247
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
248
|
+
const left = staticString(node.left);
|
|
249
|
+
const right = staticString(node.right);
|
|
250
|
+
return left !== null && right !== null ? left + right : null;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
function isProcessEnv(node) {
|
|
255
|
+
return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "process" && node.property.type === "Identifier" && node.property.name === "env";
|
|
256
|
+
}
|
|
257
|
+
function classifyMethodUse(file, module, method) {
|
|
258
|
+
const api = `${module}.${method}`;
|
|
259
|
+
if (module === "fs" || module === "node:fs" || module === "fs/promises" || module === "node:fs/promises") {
|
|
260
|
+
if (FS_WRITE_METHODS.has(method)) return {
|
|
261
|
+
caps: ["fs", "fs-write"],
|
|
262
|
+
api
|
|
263
|
+
};
|
|
264
|
+
if (FS_READ_METHODS.has(method)) return {
|
|
265
|
+
caps: ["fs"],
|
|
266
|
+
api
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
caps: [],
|
|
270
|
+
api
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (module === "child_process" || module === "node:child_process") {
|
|
274
|
+
if (SHELL_METHODS.has(method)) return {
|
|
275
|
+
caps: ["shell"],
|
|
276
|
+
api
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
caps: [],
|
|
280
|
+
api
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (module === "os" || module === "node:os") {
|
|
284
|
+
if (HOMEDIR_METHODS.has(method)) return {
|
|
285
|
+
caps: ["homedir"],
|
|
286
|
+
api
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
caps: [],
|
|
290
|
+
api
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (BUILTIN_BASE[module]?.includes("net")) {
|
|
294
|
+
if (NET_METHODS.has(method)) return {
|
|
295
|
+
caps: ["net"],
|
|
296
|
+
api
|
|
297
|
+
};
|
|
298
|
+
return {
|
|
299
|
+
caps: [],
|
|
300
|
+
api
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
caps: [],
|
|
305
|
+
api
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Walk one parsed file, collecting imports, capability uses, and obfuscation
|
|
310
|
+
* signals into the shared analysis arrays.
|
|
311
|
+
*/
|
|
312
|
+
function inspectFile(analysis, file, ast) {
|
|
313
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
314
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
315
|
+
const pushCap = (cap, line, api, literals, snippet) => {
|
|
316
|
+
const key = `${file.path}:${line}:${api}:${cap}`;
|
|
317
|
+
if (seenLines.has(key)) return;
|
|
318
|
+
seenLines.add(key);
|
|
319
|
+
analysis.capUses.push({
|
|
320
|
+
cap,
|
|
321
|
+
file: file.path,
|
|
322
|
+
line,
|
|
323
|
+
api,
|
|
324
|
+
literals,
|
|
325
|
+
snippet
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
const visit = (node) => {
|
|
329
|
+
if (!node || typeof node !== "object") return;
|
|
330
|
+
const n = node;
|
|
331
|
+
const here = node;
|
|
332
|
+
const line = here.loc?.start.line ?? 0;
|
|
333
|
+
switch (here.type) {
|
|
334
|
+
case "ImportDeclaration":
|
|
335
|
+
case "ExportNamedDeclaration":
|
|
336
|
+
case "ExportAllDeclaration": {
|
|
337
|
+
const source = n.source;
|
|
338
|
+
if (source?.value) {
|
|
339
|
+
file.imports.push({
|
|
340
|
+
specifier: source.value,
|
|
341
|
+
line
|
|
342
|
+
});
|
|
343
|
+
if (!source.value.startsWith(".")) file.externals.push(source.value);
|
|
344
|
+
const spec = source.value.replace(/^node:/, "");
|
|
345
|
+
if (here.type === "ImportDeclaration") {
|
|
346
|
+
for (const spec2 of n.specifiers ?? []) if (spec2.type === "ImportDefaultSpecifier" || spec2.type === "ImportNamespaceSpecifier") bindings.set(spec2.local.name, { module: spec });
|
|
347
|
+
else if (spec2.type === "ImportSpecifier") bindings.set(spec2.local.name, {
|
|
348
|
+
module: spec,
|
|
349
|
+
imported: spec2.imported.name
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "VariableDeclarator": {
|
|
356
|
+
const init = n.init;
|
|
357
|
+
if (n.id?.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require") {
|
|
358
|
+
const arg = staticString(init.arguments[0]);
|
|
359
|
+
if (arg !== null) {
|
|
360
|
+
file.imports.push({
|
|
361
|
+
specifier: arg,
|
|
362
|
+
line
|
|
363
|
+
});
|
|
364
|
+
if (!arg.startsWith(".")) file.externals.push(arg);
|
|
365
|
+
bindings.set(n.id.name, { module: arg.replace(/^node:/, "") });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (n.id?.type === "ObjectPattern" && init?.type === "CallExpression") {
|
|
369
|
+
const arg = staticString(init.arguments[0]);
|
|
370
|
+
if (arg !== null && init.callee.type === "Identifier" && init.callee.name === "require") {
|
|
371
|
+
for (const prop of n.id.properties ?? []) if (prop.type === "Property" && prop.value.type === "Identifier" && prop.key.type === "Identifier") bindings.set(prop.value.name, {
|
|
372
|
+
module: arg.replace(/^node:/, ""),
|
|
373
|
+
imported: prop.key.name
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case "MemberExpression":
|
|
380
|
+
if (isProcessEnv(here) || isProcessEnv(n.object)) pushCap("env", line, "process.env", [], snippetAt(file.code, line));
|
|
381
|
+
break;
|
|
382
|
+
case "CallExpression":
|
|
383
|
+
case "NewExpression": {
|
|
384
|
+
const callee = n.callee;
|
|
385
|
+
const args = n.arguments ?? [];
|
|
386
|
+
const literals = argLiterals(args);
|
|
387
|
+
const snippet = snippetAt(file.code, line);
|
|
388
|
+
const isNew = here.type === "NewExpression";
|
|
389
|
+
if (callee.type === "Identifier") {
|
|
390
|
+
const name = callee.name;
|
|
391
|
+
const binding = bindings.get(name);
|
|
392
|
+
if (name === "eval") analysis.evalUses.push({
|
|
393
|
+
file: file.path,
|
|
394
|
+
line,
|
|
395
|
+
kind: "eval",
|
|
396
|
+
literal: literals.length > 0,
|
|
397
|
+
snippet
|
|
398
|
+
});
|
|
399
|
+
else if (name === "Function" && isNew) analysis.evalUses.push({
|
|
400
|
+
file: file.path,
|
|
401
|
+
line,
|
|
402
|
+
kind: "Function",
|
|
403
|
+
literal: literals.length > 0,
|
|
404
|
+
snippet
|
|
405
|
+
});
|
|
406
|
+
else if (name === "fetch" || name === "WebSocket") {
|
|
407
|
+
analysis.netUses.push({
|
|
408
|
+
file: file.path,
|
|
409
|
+
line,
|
|
410
|
+
api: name,
|
|
411
|
+
literals,
|
|
412
|
+
snippet
|
|
413
|
+
});
|
|
414
|
+
pushCap("net", line, name, literals, snippet);
|
|
415
|
+
} else if (name === "require" && !isNew) {
|
|
416
|
+
const arg = args[0];
|
|
417
|
+
const value = staticString(arg);
|
|
418
|
+
if (arg?.type === "Literal" && value !== null) {
|
|
419
|
+
file.imports.push({
|
|
420
|
+
specifier: value,
|
|
421
|
+
line
|
|
422
|
+
});
|
|
423
|
+
if (!value.startsWith(".")) file.externals.push(value);
|
|
424
|
+
} else if (value !== null) {
|
|
425
|
+
file.imports.push({
|
|
426
|
+
specifier: value,
|
|
427
|
+
line
|
|
428
|
+
});
|
|
429
|
+
if (!value.startsWith(".")) file.externals.push(value);
|
|
430
|
+
analysis.dynamicImports.push({
|
|
431
|
+
file: file.path,
|
|
432
|
+
line,
|
|
433
|
+
kind: "require",
|
|
434
|
+
literals: [value],
|
|
435
|
+
snippet
|
|
436
|
+
});
|
|
437
|
+
} else analysis.dynamicImports.push({
|
|
438
|
+
file: file.path,
|
|
439
|
+
line,
|
|
440
|
+
kind: "require",
|
|
441
|
+
literals: null,
|
|
442
|
+
snippet
|
|
443
|
+
});
|
|
444
|
+
} else if (name === "Worker" && binding?.module === "worker_threads") pushCap("workers", line, "worker_threads.Worker", literals, snippet);
|
|
445
|
+
else if (binding) {
|
|
446
|
+
if ("imported" in binding) {
|
|
447
|
+
const { caps, api } = classifyMethodUse(file, binding.module, binding.imported);
|
|
448
|
+
for (const cap of caps) pushCap(cap, line, api, literals, snippet);
|
|
449
|
+
if (BUILTIN_BASE[binding.module]?.includes("net") && NET_METHODS.has(binding.imported)) analysis.netUses.push({
|
|
450
|
+
file: file.path,
|
|
451
|
+
line,
|
|
452
|
+
api,
|
|
453
|
+
literals,
|
|
454
|
+
snippet
|
|
455
|
+
});
|
|
456
|
+
} else if (BUILTIN_BASE[binding.module]) for (const cap of BUILTIN_BASE[binding.module]) pushCap(cap, line, binding.module, literals, snippet);
|
|
457
|
+
}
|
|
458
|
+
} else if (callee.type === "MemberExpression") {
|
|
459
|
+
const object = callee.object;
|
|
460
|
+
const property = callee.property;
|
|
461
|
+
const propName = property.type === "Identifier" && !callee.computed ? property.name : null;
|
|
462
|
+
if (object.type === "Identifier") {
|
|
463
|
+
const binding = bindings.get(object.name);
|
|
464
|
+
if (binding && propName) {
|
|
465
|
+
const method = "imported" in binding ? binding.imported : propName;
|
|
466
|
+
const module = binding.module;
|
|
467
|
+
const { caps, api } = classifyMethodUse(file, module, method);
|
|
468
|
+
for (const cap of caps) {
|
|
469
|
+
const enriched = cap === "fs" && FS_READ_METHODS.has(method) && literals.some((l) => CREDENTIAL_PATH_RE.test(l)) ? [cap, "secret-read"] : [cap];
|
|
470
|
+
for (const c of enriched) pushCap(c, line, api, literals, snippet);
|
|
471
|
+
}
|
|
472
|
+
if (BUILTIN_BASE[module]?.includes("net") && NET_METHODS.has(method)) analysis.netUses.push({
|
|
473
|
+
file: file.path,
|
|
474
|
+
line,
|
|
475
|
+
api,
|
|
476
|
+
literals,
|
|
477
|
+
snippet
|
|
478
|
+
});
|
|
479
|
+
} else if (object.name === "String" && propName === "fromCharCode") {
|
|
480
|
+
const nums = args.filter((a) => a.type === "Literal" && typeof a.value === "number");
|
|
481
|
+
if (nums.length >= 8) analysis.charcodeCalls.push({
|
|
482
|
+
file: file.path,
|
|
483
|
+
line,
|
|
484
|
+
chars: String.fromCharCode(...nums.map((a) => a.value)).slice(0, 48)
|
|
485
|
+
});
|
|
486
|
+
} else if (object.name === "http" || object.name === "https" || object.name === "net") {
|
|
487
|
+
if (NET_METHODS.has(propName ?? "")) {
|
|
488
|
+
analysis.netUses.push({
|
|
489
|
+
file: file.path,
|
|
490
|
+
line,
|
|
491
|
+
api: `${object.name}.${propName}`,
|
|
492
|
+
literals,
|
|
493
|
+
snippet
|
|
494
|
+
});
|
|
495
|
+
pushCap("net", line, `${object.name}.${propName}`, literals, snippet);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} else if (object.type === "MemberExpression" && isProcessEnv(object)) pushCap("env", line, "process.env", [], snippet);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case "ImportExpression": {
|
|
503
|
+
const source = n.source;
|
|
504
|
+
const value = staticString(source);
|
|
505
|
+
const snippet = snippetAt(file.code, line);
|
|
506
|
+
if (source?.type === "Literal" && value !== null) {
|
|
507
|
+
file.imports.push({
|
|
508
|
+
specifier: value,
|
|
509
|
+
line
|
|
510
|
+
});
|
|
511
|
+
if (!value.startsWith(".")) file.externals.push(value);
|
|
512
|
+
} else if (value !== null) {
|
|
513
|
+
file.imports.push({
|
|
514
|
+
specifier: value,
|
|
515
|
+
line
|
|
516
|
+
});
|
|
517
|
+
if (!value.startsWith(".")) file.externals.push(value);
|
|
518
|
+
analysis.dynamicImports.push({
|
|
519
|
+
file: file.path,
|
|
520
|
+
line,
|
|
521
|
+
kind: "import",
|
|
522
|
+
literals: [value],
|
|
523
|
+
snippet
|
|
524
|
+
});
|
|
525
|
+
} else analysis.dynamicImports.push({
|
|
526
|
+
file: file.path,
|
|
527
|
+
line,
|
|
528
|
+
kind: "import",
|
|
529
|
+
literals: null,
|
|
530
|
+
snippet
|
|
531
|
+
});
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case "Literal": {
|
|
535
|
+
const value = n.value;
|
|
536
|
+
if (typeof value === "string" && value.length >= 48 && !/\s/.test(value)) {
|
|
537
|
+
if (/^[A-Za-z0-9+/=]+$/.test(value) && /\d/.test(value) && /[A-Z]/.test(value)) analysis.encodedLiterals.push({
|
|
538
|
+
file: file.path,
|
|
539
|
+
line,
|
|
540
|
+
value,
|
|
541
|
+
charset: "base64"
|
|
542
|
+
});
|
|
543
|
+
else if (/^[0-9a-fA-F]+$/.test(value)) {
|
|
544
|
+
if (new Set(value).size >= 8) analysis.encodedLiterals.push({
|
|
545
|
+
file: file.path,
|
|
546
|
+
line,
|
|
547
|
+
value,
|
|
548
|
+
charset: "hex"
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const [key, value] of Object.entries(n)) {
|
|
556
|
+
if (key === "loc" || key === "start" || key === "end" || key === "range") continue;
|
|
557
|
+
if (Array.isArray(value)) for (const child of value) visit(child);
|
|
558
|
+
else if (value && typeof value === "object" && "type" in value) visit(value);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
visit(ast);
|
|
562
|
+
}
|
|
563
|
+
function resolveRelative(rootDir, fromFile, spec) {
|
|
564
|
+
const base = resolve(rootDir, dirname(fromFile), spec);
|
|
565
|
+
const candidates = [
|
|
566
|
+
base,
|
|
567
|
+
`${base}.js`,
|
|
568
|
+
`${base}.cjs`,
|
|
569
|
+
`${base}.mjs`,
|
|
570
|
+
`${base}.json`,
|
|
571
|
+
join(base, "index.js"),
|
|
572
|
+
join(base, "index.cjs"),
|
|
573
|
+
join(base, "index.mjs")
|
|
574
|
+
];
|
|
575
|
+
for (const candidate of candidates) try {
|
|
576
|
+
readFileSync(candidate);
|
|
577
|
+
return toPosix(relative(rootDir, candidate));
|
|
578
|
+
} catch {}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
function flattenExports(node, out) {
|
|
582
|
+
if (typeof node === "string") out.push(node);
|
|
583
|
+
else if (node && typeof node === "object") for (const value of Object.values(node)) flattenExports(value, out);
|
|
584
|
+
}
|
|
585
|
+
function entryHints(pkg) {
|
|
586
|
+
const hints = /* @__PURE__ */ new Set();
|
|
587
|
+
if (pkg.raw["main"] && typeof pkg.raw["main"] === "string") hints.add(pkg.raw["main"]);
|
|
588
|
+
if (pkg.raw["module"] && typeof pkg.raw["module"] === "string") hints.add(pkg.raw["module"]);
|
|
589
|
+
const exports = pkg.raw["exports"];
|
|
590
|
+
if (exports && typeof exports === "object") {
|
|
591
|
+
const flattened = [];
|
|
592
|
+
flattenExports(exports, flattened);
|
|
593
|
+
for (const f of flattened) hints.add(f);
|
|
594
|
+
}
|
|
595
|
+
const bin = pkg.raw["bin"];
|
|
596
|
+
if (typeof bin === "string") hints.add(bin);
|
|
597
|
+
else if (bin && typeof bin === "object") {
|
|
598
|
+
for (const value of Object.values(bin)) if (typeof value === "string") hints.add(value);
|
|
599
|
+
}
|
|
600
|
+
if (hints.size === 0) hints.add("index.js");
|
|
601
|
+
return [...hints];
|
|
602
|
+
}
|
|
603
|
+
function readPkg(rootDir) {
|
|
604
|
+
try {
|
|
605
|
+
const raw = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8"));
|
|
606
|
+
const asRecord = (value) => value && typeof value === "object" ? value : {};
|
|
607
|
+
const dsh = raw["dsh"];
|
|
608
|
+
const seams = Array.isArray(dsh?.["seams"]) ? dsh["seams"] : null;
|
|
609
|
+
const pkg = {
|
|
610
|
+
raw,
|
|
611
|
+
name: typeof raw["name"] === "string" ? raw["name"] : "",
|
|
612
|
+
version: typeof raw["version"] === "string" ? raw["version"] : "",
|
|
613
|
+
scripts: asRecord(raw["scripts"]),
|
|
614
|
+
dependencies: asRecord(raw["dependencies"]),
|
|
615
|
+
devDependencies: asRecord(raw["devDependencies"]),
|
|
616
|
+
seams,
|
|
617
|
+
entryHints: []
|
|
618
|
+
};
|
|
619
|
+
pkg.entryHints = entryHints(pkg);
|
|
620
|
+
return pkg;
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const byFileLine = (a, b) => a.file.localeCompare(b.file) || a.line - b.line;
|
|
626
|
+
/** Analyze a package directory: parse, walk, and build the module graph. */
|
|
627
|
+
function analyze(rootDir) {
|
|
628
|
+
const pkg = readPkg(rootDir);
|
|
629
|
+
const analysis = {
|
|
630
|
+
rootDir,
|
|
631
|
+
pkg,
|
|
632
|
+
files: [],
|
|
633
|
+
fileByPath: /* @__PURE__ */ new Map(),
|
|
634
|
+
entries: [],
|
|
635
|
+
reachable: /* @__PURE__ */ new Set(),
|
|
636
|
+
unreachable: [],
|
|
637
|
+
edges: /* @__PURE__ */ new Map(),
|
|
638
|
+
capUses: [],
|
|
639
|
+
netUses: [],
|
|
640
|
+
evalUses: [],
|
|
641
|
+
dynamicImports: [],
|
|
642
|
+
encodedLiterals: [],
|
|
643
|
+
charcodeCalls: []
|
|
644
|
+
};
|
|
645
|
+
for (const abs of listJsFiles(rootDir)) {
|
|
646
|
+
const path = toPosix(relative(rootDir, abs));
|
|
647
|
+
const code = readFileSync(abs, "utf8");
|
|
648
|
+
const parsed = parseSource(path, code);
|
|
649
|
+
const file = {
|
|
650
|
+
path,
|
|
651
|
+
code,
|
|
652
|
+
sourceType: parsed.sourceType,
|
|
653
|
+
parseError: parsed.parseError,
|
|
654
|
+
ast: parsed.ast,
|
|
655
|
+
imports: [],
|
|
656
|
+
externals: []
|
|
657
|
+
};
|
|
658
|
+
analysis.files.push(file);
|
|
659
|
+
analysis.fileByPath.set(path, file);
|
|
660
|
+
}
|
|
661
|
+
for (const file of analysis.files) {
|
|
662
|
+
if (file.ast) inspectFile(analysis, file, file.ast);
|
|
663
|
+
file.imports.sort((a, b) => a.specifier.localeCompare(b.specifier) || a.line - b.line);
|
|
664
|
+
file.externals = [...new Set(file.externals)].sort();
|
|
665
|
+
}
|
|
666
|
+
const entries = /* @__PURE__ */ new Set();
|
|
667
|
+
for (const hint of pkg?.entryHints ?? ["index.js"]) {
|
|
668
|
+
const rel = resolveRelative(rootDir, "package.json", hint);
|
|
669
|
+
if (rel && analysis.fileByPath.has(rel)) entries.add(rel);
|
|
670
|
+
}
|
|
671
|
+
const queue = [...entries].sort();
|
|
672
|
+
const edges = /* @__PURE__ */ new Map();
|
|
673
|
+
while (queue.length > 0) {
|
|
674
|
+
const current = queue.shift();
|
|
675
|
+
if (analysis.reachable.has(current)) continue;
|
|
676
|
+
analysis.reachable.add(current);
|
|
677
|
+
const file = analysis.fileByPath.get(current);
|
|
678
|
+
if (!file) continue;
|
|
679
|
+
const targets = [];
|
|
680
|
+
for (const ref of file.imports) {
|
|
681
|
+
if (!ref.specifier.startsWith(".")) continue;
|
|
682
|
+
const rel = resolveRelative(rootDir, current, ref.specifier);
|
|
683
|
+
if (rel && analysis.fileByPath.has(rel)) {
|
|
684
|
+
targets.push(rel);
|
|
685
|
+
if (!analysis.reachable.has(rel)) queue.push(rel);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
edges.set(current, [...new Set(targets)].sort());
|
|
689
|
+
}
|
|
690
|
+
analysis.edges = edges;
|
|
691
|
+
analysis.entries = [...entries].sort();
|
|
692
|
+
analysis.unreachable = analysis.files.map((f) => f.path).filter((p) => !analysis.reachable.has(p));
|
|
693
|
+
analysis.capUses.sort((a, b) => byFileLine(a, b) || a.api.localeCompare(b.api) || a.cap.localeCompare(b.cap));
|
|
694
|
+
analysis.netUses.sort(byFileLine);
|
|
695
|
+
analysis.evalUses.sort(byFileLine);
|
|
696
|
+
analysis.dynamicImports.sort(byFileLine);
|
|
697
|
+
analysis.encodedLiterals.sort(byFileLine);
|
|
698
|
+
analysis.charcodeCalls.sort(byFileLine);
|
|
699
|
+
return analysis;
|
|
700
|
+
}
|
|
701
|
+
/** All files statically reachable from `path` (excluding the path itself). */
|
|
702
|
+
function reachableFrom(analysis, path) {
|
|
703
|
+
const seen = /* @__PURE__ */ new Set();
|
|
704
|
+
const queue = [path];
|
|
705
|
+
while (queue.length > 0) {
|
|
706
|
+
const current = queue.shift();
|
|
707
|
+
for (const next of analysis.edges.get(current) ?? []) if (!seen.has(next)) {
|
|
708
|
+
seen.add(next);
|
|
709
|
+
queue.push(next);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return seen;
|
|
713
|
+
}
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/tar.ts
|
|
716
|
+
/**
|
|
717
|
+
* Minimal tar reader for npm pack tarballs (ustar + PAX + GNU longname).
|
|
718
|
+
*
|
|
719
|
+
* Hand-rolled on purpose: it keeps `acorn` the only runtime dependency that is
|
|
720
|
+
* not Node itself (ROADMAP D4), and npm tarballs need only a small, well-
|
|
721
|
+
* understood subset of the format. Hardlinks and symlinks are never
|
|
722
|
+
* materialized — they are reported back as skipped so the scanner can surface
|
|
723
|
+
* them instead of silently following them.
|
|
724
|
+
*/
|
|
725
|
+
const BLOCK = 512;
|
|
726
|
+
function str(buf, off, len) {
|
|
727
|
+
return buf.subarray(off, off + len).toString("utf8").replace(/\0.*$/, "").trim();
|
|
728
|
+
}
|
|
729
|
+
/** Header numeric field: NUL-padded octal, or GNU base-256 for large sizes. */
|
|
730
|
+
function numeric(buf, off, len) {
|
|
731
|
+
if (buf[off] & 128) {
|
|
732
|
+
let value = buf[off] & 127;
|
|
733
|
+
for (let i = off + 1; i < off + len; i += 1) value = value * 256 + buf[i];
|
|
734
|
+
return value;
|
|
735
|
+
}
|
|
736
|
+
return parseInt(str(buf, off, len), 8) || 0;
|
|
737
|
+
}
|
|
738
|
+
/** PAX extended header records: `<len> <key>=<value>\n`. */
|
|
739
|
+
function paxRecords(data) {
|
|
740
|
+
const records = /* @__PURE__ */ new Map();
|
|
741
|
+
let pos = 0;
|
|
742
|
+
while (pos < data.length) {
|
|
743
|
+
const space = data.indexOf(" ", pos);
|
|
744
|
+
if (space < 0) break;
|
|
745
|
+
const len = parseInt(data.subarray(pos, space).toString("utf8"), 10);
|
|
746
|
+
if (!Number.isFinite(len) || len <= 0 || pos + len > data.length) break;
|
|
747
|
+
const record = data.subarray(space + 1, pos + len).toString("utf8");
|
|
748
|
+
const eq = record.indexOf("=");
|
|
749
|
+
if (eq > 0) records.set(record.slice(0, eq), record.slice(eq + 1).replace(/\n$/, ""));
|
|
750
|
+
pos += len;
|
|
751
|
+
}
|
|
752
|
+
return records;
|
|
753
|
+
}
|
|
754
|
+
function stripPackagePrefix(name) {
|
|
755
|
+
let p = name;
|
|
756
|
+
if (p.startsWith("./")) p = p.slice(2);
|
|
757
|
+
const slash = p.indexOf("/");
|
|
758
|
+
if (p.startsWith("package/")) p = p.slice(8);
|
|
759
|
+
else if (slash === -1 && p === "package") p = "";
|
|
760
|
+
return p;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Extract a (possibly gzipped) tar buffer into `destDir`. Entry paths must
|
|
764
|
+
* stay inside `destDir`; anything that escapes — `..`, absolute paths — is a
|
|
765
|
+
* malformed or hostile archive and throws rather than being written.
|
|
766
|
+
*/
|
|
767
|
+
function extractTarball(tarball, destDir) {
|
|
768
|
+
const buf = tarball[0] === 31 && tarball[1] === 139 ? gunzipSync(tarball) : tarball;
|
|
769
|
+
const root = resolve(destDir);
|
|
770
|
+
const files = [];
|
|
771
|
+
const skipped = [];
|
|
772
|
+
let pendingPath;
|
|
773
|
+
let globalPax = /* @__PURE__ */ new Map();
|
|
774
|
+
let pos = 0;
|
|
775
|
+
while (pos + BLOCK <= buf.length) {
|
|
776
|
+
const header = buf.subarray(pos, pos + BLOCK);
|
|
777
|
+
if (header.every((byte) => byte === 0)) break;
|
|
778
|
+
pos += BLOCK;
|
|
779
|
+
const size = numeric(header, 124, 12);
|
|
780
|
+
const typeflag = String.fromCharCode(header[156]);
|
|
781
|
+
const data = buf.subarray(pos, pos + size);
|
|
782
|
+
pos += size + (BLOCK - size % BLOCK) % BLOCK;
|
|
783
|
+
if (typeflag === "x" || typeflag === "X") {
|
|
784
|
+
pendingPath = paxRecords(data).get("path");
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (typeflag === "g") {
|
|
788
|
+
globalPax = paxRecords(data);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (typeflag === "L") {
|
|
792
|
+
pendingPath = data.toString("utf8").replace(/\0.*$/, "").trim();
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
let name = pendingPath ?? globalPax.get("path") ?? str(header, 345, 155) + str(header, 0, 100);
|
|
796
|
+
pendingPath = void 0;
|
|
797
|
+
name = stripPackagePrefix(name);
|
|
798
|
+
if (!name || name === "." || name === "./") continue;
|
|
799
|
+
if (typeflag === "1" || typeflag === "2") {
|
|
800
|
+
skipped.push({
|
|
801
|
+
path: name,
|
|
802
|
+
type: typeflag === "1" ? "hardlink" : "symlink",
|
|
803
|
+
target: str(header, 157, 100)
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (typeflag === "5") {
|
|
808
|
+
mkdirSync(join(root, name), { recursive: true });
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (typeflag !== "0" && typeflag !== "\0" && typeflag !== "7") continue;
|
|
812
|
+
const parts = name.split("/");
|
|
813
|
+
if (name.startsWith("/") || parts.includes("..")) throw new Error(`tarball entry escapes destination: ${name}`);
|
|
814
|
+
const abs = resolve(root, name);
|
|
815
|
+
if (abs !== root && !abs.startsWith(root + sep)) throw new Error(`tarball entry escapes destination: ${name}`);
|
|
816
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
817
|
+
writeFileSync(abs, data);
|
|
818
|
+
const mode = numeric(header, 100, 8);
|
|
819
|
+
if (mode & 73) chmodSync(abs, mode);
|
|
820
|
+
files.push(name);
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
files: files.sort(),
|
|
824
|
+
skipped
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/resolve.ts
|
|
829
|
+
/**
|
|
830
|
+
* Target resolution (ROADMAP T1): turn a CLI specifier into a local directory
|
|
831
|
+
* ready for analysis, plus the `VetTarget` metadata the report carries.
|
|
832
|
+
*
|
|
833
|
+
* - npm packages are fetched through registry metadata only (packument →
|
|
834
|
+
* dist.tarball), and the downloaded tarball must match `dist.integrity`
|
|
835
|
+
* before anything is extracted.
|
|
836
|
+
* - local paths never touch the network.
|
|
837
|
+
* - git repos are cloned with `git clone --depth 1`; cloning runs no package
|
|
838
|
+
* code (no lifecycle scripts), unlike an install.
|
|
839
|
+
*/
|
|
840
|
+
var VetError = class extends Error {};
|
|
841
|
+
const GIT_RE = /^(?:git\+)?(?:ssh:\/\/|https?:\/\/|git@)[^\s]+$|^(?:github|gitlab|bitbucket):[^\s]+$/;
|
|
842
|
+
/** Classify a specifier without any network access. */
|
|
843
|
+
function classifySpecifier(specifier, exists = existsSync) {
|
|
844
|
+
const s = specifier.trim();
|
|
845
|
+
if (s.startsWith("./") || s.startsWith("../") || s.startsWith("/") || s.startsWith("~")) return "local-path";
|
|
846
|
+
if (GIT_RE.test(s)) return "git-repo";
|
|
847
|
+
if (exists(s)) return "local-path";
|
|
848
|
+
if (/^@[^/\s]+\/[^@\s]+(?:@[^@\s]+)?$/.test(s) || /^[^@\s][^/\s]*$/.test(s)) return "npm-package";
|
|
849
|
+
throw new VetError(`cannot classify specifier: ${specifier}`);
|
|
850
|
+
}
|
|
851
|
+
/** Split `name`, `name@version`, `@scope/name`, `@scope/name@tag|version`. */
|
|
852
|
+
function parseNpmSpecifier(specifier) {
|
|
853
|
+
const scoped = specifier.startsWith("@");
|
|
854
|
+
const base = scoped ? specifier.slice(1) : specifier;
|
|
855
|
+
const at = base.lastIndexOf("@");
|
|
856
|
+
if (at < 0) return { name: specifier };
|
|
857
|
+
const name = (scoped ? "@" : "") + base.slice(0, at);
|
|
858
|
+
const spec = base.slice(at + 1);
|
|
859
|
+
if (!name || !spec) return { name: specifier };
|
|
860
|
+
return {
|
|
861
|
+
name,
|
|
862
|
+
spec
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function registryPath(name) {
|
|
866
|
+
return name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
867
|
+
}
|
|
868
|
+
async function fetchJson(url, opts) {
|
|
869
|
+
const res = await (opts.fetchImpl ?? ((u) => fetch(u)))(url);
|
|
870
|
+
if (!res.ok) throw new VetError(`registry request failed (${res.status}) for ${url}`);
|
|
871
|
+
return res.json();
|
|
872
|
+
}
|
|
873
|
+
async function fetchBuffer(url, opts) {
|
|
874
|
+
const res = await (opts.fetchImpl ?? ((u) => fetch(u)))(url);
|
|
875
|
+
if (!res.ok) throw new VetError(`tarball request failed (${res.status}) for ${url}`);
|
|
876
|
+
return Buffer.from(await res.arrayBuffer());
|
|
877
|
+
}
|
|
878
|
+
function verifyIntegrity(tarball, integrity, shasum) {
|
|
879
|
+
if (integrity) {
|
|
880
|
+
const match = /^sha(512|384|256|1)-([A-Za-z0-9+/=]+)$/.exec(integrity);
|
|
881
|
+
if (!match) throw new VetError(`unsupported integrity format: ${integrity}`);
|
|
882
|
+
const [, bits, expected] = match;
|
|
883
|
+
const actual = createHash(`sha${bits}`).update(tarball).digest("base64");
|
|
884
|
+
if (actual !== expected) throw new VetError(`integrity mismatch for downloaded tarball (expected ${integrity}, got sha${bits}-${actual})`);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (shasum) {
|
|
888
|
+
if (createHash("sha1").update(tarball).digest("hex") !== shasum) throw new VetError(`shasum mismatch for downloaded tarball`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function tmpWorkspace() {
|
|
892
|
+
return mkdtempSync(join(tmpdir(), "dsh-vet-"));
|
|
893
|
+
}
|
|
894
|
+
function cleanupDir(dir) {
|
|
895
|
+
rmSync(dir, {
|
|
896
|
+
recursive: true,
|
|
897
|
+
force: true
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
async function resolveNpm(specifier, opts) {
|
|
901
|
+
const registry = (opts.registry ?? "https://registry.npmjs.org").replace(/\/$/, "");
|
|
902
|
+
const { name, spec } = parseNpmSpecifier(specifier);
|
|
903
|
+
const packument = await fetchJson(`${registry}/${registryPath(name)}`, opts);
|
|
904
|
+
const versions = packument?.versions ?? {};
|
|
905
|
+
const distTags = packument?.["dist-tags"] ?? {};
|
|
906
|
+
const version = spec ? versions[spec]?.version ?? distTags[spec] : distTags["latest"] ?? Object.keys(versions).at(-1);
|
|
907
|
+
if (!version || !versions[version]) throw new VetError(`no matching version for ${specifier} (resolved candidate: ${version ?? "none"})`);
|
|
908
|
+
const dist = versions[version]?.dist ?? {};
|
|
909
|
+
if (!dist.tarball) throw new VetError(`registry metadata for ${name}@${version} has no tarball URL`);
|
|
910
|
+
const tarball = await fetchBuffer(dist.tarball, opts);
|
|
911
|
+
verifyIntegrity(tarball, dist.integrity, dist.shasum);
|
|
912
|
+
const work = tmpWorkspace();
|
|
913
|
+
try {
|
|
914
|
+
const extracted = extractTarball(tarball, work);
|
|
915
|
+
return {
|
|
916
|
+
target: {
|
|
917
|
+
kind: "npm-package",
|
|
918
|
+
specifier,
|
|
919
|
+
resolved: {
|
|
920
|
+
version,
|
|
921
|
+
integrity: dist.integrity
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
rootDir: work,
|
|
925
|
+
files: extracted.files,
|
|
926
|
+
skippedLinks: extracted.skipped,
|
|
927
|
+
cleanup: () => cleanupDir(work)
|
|
928
|
+
};
|
|
929
|
+
} catch (err) {
|
|
930
|
+
cleanupDir(work);
|
|
931
|
+
throw err;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
function runGit(args) {
|
|
935
|
+
return new Promise((resolvePromise, reject) => {
|
|
936
|
+
const child = spawn("git", args, { stdio: [
|
|
937
|
+
"ignore",
|
|
938
|
+
"pipe",
|
|
939
|
+
"pipe"
|
|
940
|
+
] });
|
|
941
|
+
let stdout = "";
|
|
942
|
+
let stderr = "";
|
|
943
|
+
child.stdout.on("data", (chunk) => stdout += chunk);
|
|
944
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
945
|
+
child.on("error", (err) => reject(new VetError(`git is not available: ${err.message}`)));
|
|
946
|
+
child.on("close", (code) => code === 0 ? resolvePromise({
|
|
947
|
+
stdout,
|
|
948
|
+
stderr
|
|
949
|
+
}) : reject(new VetError(`git ${args[0]} failed (${code}): ${stderr.trim()}`)));
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async function resolveGit(specifier) {
|
|
953
|
+
const url = specifier.replace(/^git\+/, "");
|
|
954
|
+
const work = tmpWorkspace();
|
|
955
|
+
try {
|
|
956
|
+
await runGit([
|
|
957
|
+
"clone",
|
|
958
|
+
"--depth",
|
|
959
|
+
"1",
|
|
960
|
+
"--quiet",
|
|
961
|
+
url,
|
|
962
|
+
work
|
|
963
|
+
]);
|
|
964
|
+
const { stdout } = await runGit([
|
|
965
|
+
"-C",
|
|
966
|
+
work,
|
|
967
|
+
"rev-parse",
|
|
968
|
+
"HEAD"
|
|
969
|
+
]);
|
|
970
|
+
return {
|
|
971
|
+
target: {
|
|
972
|
+
kind: "git-repo",
|
|
973
|
+
specifier: url,
|
|
974
|
+
resolved: { commit: stdout.trim() }
|
|
975
|
+
},
|
|
976
|
+
rootDir: work,
|
|
977
|
+
cleanup: () => cleanupDir(work)
|
|
978
|
+
};
|
|
979
|
+
} catch (err) {
|
|
980
|
+
cleanupDir(work);
|
|
981
|
+
throw err;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function resolveLocal(specifier) {
|
|
985
|
+
const dir = specifier.startsWith("~/") ? resolve(homedir(), specifier.slice(2)) : resolve(specifier);
|
|
986
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new VetError(`local path is not a directory: ${specifier}`);
|
|
987
|
+
return {
|
|
988
|
+
target: {
|
|
989
|
+
kind: "local-path",
|
|
990
|
+
specifier: dir
|
|
991
|
+
},
|
|
992
|
+
rootDir: dir,
|
|
993
|
+
cleanup: () => {}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/** Resolve any accepted specifier into a scannable local directory. */
|
|
997
|
+
async function resolveTarget(specifier, opts = {}) {
|
|
998
|
+
switch (classifySpecifier(specifier)) {
|
|
999
|
+
case "npm-package": return resolveNpm(specifier, opts);
|
|
1000
|
+
case "git-repo": return resolveGit(specifier);
|
|
1001
|
+
case "local-path": return resolveLocal(specifier);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region src/rule.ts
|
|
1006
|
+
/** Build a finding from a rule, applying its defaults. */
|
|
1007
|
+
function finding(rule, init) {
|
|
1008
|
+
return {
|
|
1009
|
+
id: rule.id,
|
|
1010
|
+
title: init.title ?? rule.title,
|
|
1011
|
+
severity: init.severity ?? rule.defaultSeverity,
|
|
1012
|
+
confidence: init.confidence ?? rule.defaultConfidence,
|
|
1013
|
+
evidence: init.evidence,
|
|
1014
|
+
remediation: init.remediation,
|
|
1015
|
+
references: init.references
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/** Evidence list capped at `max`, with a trailing note counting the rest. */
|
|
1019
|
+
function capEvidence(evidence, max = 10) {
|
|
1020
|
+
if (evidence.length <= max) return evidence;
|
|
1021
|
+
return [...evidence.slice(0, max), {
|
|
1022
|
+
file: ".",
|
|
1023
|
+
note: `…and ${evidence.length - max} more`
|
|
1024
|
+
}];
|
|
1025
|
+
}
|
|
1026
|
+
function usesOfCap(analysis, cap) {
|
|
1027
|
+
return analysis.capUses.filter((use) => use.cap === cap);
|
|
1028
|
+
}
|
|
1029
|
+
/** Case-insensitive seam membership; null means "no declaration made". */
|
|
1030
|
+
function declaresSeam(analysis, seam) {
|
|
1031
|
+
if (!analysis.pkg?.seams) return null;
|
|
1032
|
+
return analysis.pkg.seams.some((s) => s.toLowerCase() === seam.toLowerCase());
|
|
1033
|
+
}
|
|
1034
|
+
/** Bounded Levenshtein distance: returns `max + 1` once exceeded. */
|
|
1035
|
+
function editDistance(a, b, max = 2) {
|
|
1036
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
1037
|
+
const prev = new Array(b.length + 1);
|
|
1038
|
+
const curr = new Array(b.length + 1);
|
|
1039
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = j;
|
|
1040
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1041
|
+
curr[0] = i;
|
|
1042
|
+
let rowMin = curr[0];
|
|
1043
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1044
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1045
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
1046
|
+
if (curr[j] < rowMin) rowMin = curr[j];
|
|
1047
|
+
}
|
|
1048
|
+
if (rowMin > max) return max + 1;
|
|
1049
|
+
for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j];
|
|
1050
|
+
}
|
|
1051
|
+
return prev[b.length];
|
|
1052
|
+
}
|
|
1053
|
+
//#endregion
|
|
1054
|
+
//#region src/rules/dep.ts
|
|
1055
|
+
const LIFECYCLE_SCRIPTS = [
|
|
1056
|
+
"preinstall",
|
|
1057
|
+
"install",
|
|
1058
|
+
"postinstall"
|
|
1059
|
+
];
|
|
1060
|
+
const installScripts = {
|
|
1061
|
+
id: "dep.install-scripts",
|
|
1062
|
+
title: "Runs code at install time",
|
|
1063
|
+
defaultSeverity: "medium",
|
|
1064
|
+
defaultConfidence: "high",
|
|
1065
|
+
check({ analysis }) {
|
|
1066
|
+
const scripts = analysis.pkg?.scripts ?? {};
|
|
1067
|
+
const present = LIFECYCLE_SCRIPTS.filter((name) => typeof scripts[name] === "string" && scripts[name] !== "");
|
|
1068
|
+
if (present.length === 0) return [];
|
|
1069
|
+
return [finding(this, {
|
|
1070
|
+
evidence: present.map((name) => ({
|
|
1071
|
+
file: "package.json",
|
|
1072
|
+
snippet: scripts[name],
|
|
1073
|
+
note: `${name} script runs when the package is installed`
|
|
1074
|
+
})),
|
|
1075
|
+
remediation: "Remove the lifecycle script, or move the work to a prepublish/build step and document why install-time execution is required.",
|
|
1076
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/dep.install-scripts.md"]
|
|
1077
|
+
})];
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
const FLOATING = /* @__PURE__ */ new Set([
|
|
1081
|
+
"",
|
|
1082
|
+
"*",
|
|
1083
|
+
"latest",
|
|
1084
|
+
"x",
|
|
1085
|
+
"X"
|
|
1086
|
+
]);
|
|
1087
|
+
const floatingRange = {
|
|
1088
|
+
id: "dep.floating-range",
|
|
1089
|
+
title: "Runtime dependencies resolve to whatever the registry serves",
|
|
1090
|
+
defaultSeverity: "medium",
|
|
1091
|
+
defaultConfidence: "high",
|
|
1092
|
+
check({ analysis }) {
|
|
1093
|
+
const deps = analysis.pkg?.dependencies ?? {};
|
|
1094
|
+
const floating = Object.entries(deps).filter(([, spec]) => FLOATING.has(spec.trim()));
|
|
1095
|
+
if (floating.length === 0) return [];
|
|
1096
|
+
return [finding(this, {
|
|
1097
|
+
evidence: floating.map(([name, spec]) => ({
|
|
1098
|
+
file: "package.json",
|
|
1099
|
+
note: `"${name}": "${spec}" — any future version satisfies this`
|
|
1100
|
+
})),
|
|
1101
|
+
remediation: "Pin to a exact version or a narrow range.",
|
|
1102
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/dep.floating-range.md"]
|
|
1103
|
+
})];
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
/**
|
|
1107
|
+
* Popular `dsh-*` names worth impersonating. Every entry is verified to exist
|
|
1108
|
+
* on npm (checked during the v0.1 calibration sweep,
|
|
1109
|
+
* docs/calibration-v0.1.md); a name that cannot be installed cannot be
|
|
1110
|
+
* typosquatted. Extend as adoption grows.
|
|
1111
|
+
*/
|
|
1112
|
+
const POPULAR_NAMES = [
|
|
1113
|
+
"dsh-doctor",
|
|
1114
|
+
"dsh-plugin-audit",
|
|
1115
|
+
"dsh-plugin-vetting",
|
|
1116
|
+
"dsh-audit",
|
|
1117
|
+
"dsh-searxng",
|
|
1118
|
+
"dsh-vault",
|
|
1119
|
+
"dsh-zcf",
|
|
1120
|
+
"dsh-wechat",
|
|
1121
|
+
"dsh-better-sidebar",
|
|
1122
|
+
"dsh-find-plugin"
|
|
1123
|
+
];
|
|
1124
|
+
const depRules = [
|
|
1125
|
+
installScripts,
|
|
1126
|
+
floatingRange,
|
|
1127
|
+
{
|
|
1128
|
+
id: "dep.typosquat-proximity",
|
|
1129
|
+
title: "Dependency name is one or two edits from a popular dsh-* package",
|
|
1130
|
+
defaultSeverity: "high",
|
|
1131
|
+
defaultConfidence: "medium",
|
|
1132
|
+
check({ analysis }) {
|
|
1133
|
+
const deps = Object.keys(analysis.pkg?.dependencies ?? {});
|
|
1134
|
+
const hits = [];
|
|
1135
|
+
for (const dep of deps) {
|
|
1136
|
+
if (POPULAR_NAMES.includes(dep)) continue;
|
|
1137
|
+
for (const popular of POPULAR_NAMES) {
|
|
1138
|
+
const distance = editDistance(dep, popular, 2);
|
|
1139
|
+
if (distance <= 2) hits.push({
|
|
1140
|
+
dep,
|
|
1141
|
+
near: popular,
|
|
1142
|
+
distance
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (hits.length === 0) return [];
|
|
1147
|
+
hits.sort((a, b) => a.distance - b.distance || a.dep.localeCompare(b.dep));
|
|
1148
|
+
const worst = hits[0].distance;
|
|
1149
|
+
return [finding(this, {
|
|
1150
|
+
severity: worst === 1 ? "high" : "low",
|
|
1151
|
+
evidence: hits.slice(0, 10).map((hit) => ({
|
|
1152
|
+
file: "package.json",
|
|
1153
|
+
note: `"${hit.dep}" is ${hit.distance} edit${hit.distance === 1 ? "" : "s"} from "${hit.near}"`
|
|
1154
|
+
})),
|
|
1155
|
+
remediation: "Verify the dependency is the package you mean — exact spelling, real repository, real publisher.",
|
|
1156
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/dep.typosquat-proximity.md"]
|
|
1157
|
+
})];
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
];
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/rules/egress.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* `egress.*` — data egress (ROADMAP T3): outbound endpoint inventory, and
|
|
1165
|
+
* endpoints reachable from code that reads secrets or the DSH home directory.
|
|
1166
|
+
*/
|
|
1167
|
+
function endpointOf(literal) {
|
|
1168
|
+
try {
|
|
1169
|
+
return new URL(literal).host;
|
|
1170
|
+
} catch {
|
|
1171
|
+
return literal;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const outboundEndpoints = {
|
|
1175
|
+
id: "egress.outbound-endpoints",
|
|
1176
|
+
title: "Outbound endpoints the code can contact",
|
|
1177
|
+
defaultSeverity: "info",
|
|
1178
|
+
defaultConfidence: "high",
|
|
1179
|
+
check({ analysis }) {
|
|
1180
|
+
const byEndpoint = /* @__PURE__ */ new Map();
|
|
1181
|
+
for (const use of analysis.netUses) for (const literal of use.literals) {
|
|
1182
|
+
const endpoint = endpointOf(literal);
|
|
1183
|
+
if (!endpoint) continue;
|
|
1184
|
+
if (!byEndpoint.has(endpoint)) byEndpoint.set(endpoint, {
|
|
1185
|
+
file: use.file,
|
|
1186
|
+
line: use.line,
|
|
1187
|
+
snippet: use.snippet
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
if (byEndpoint.size === 0) return [];
|
|
1191
|
+
const endpoints = [...byEndpoint.keys()].sort();
|
|
1192
|
+
return [finding(this, {
|
|
1193
|
+
evidence: endpoints.map((endpoint) => ({
|
|
1194
|
+
...byEndpoint.get(endpoint),
|
|
1195
|
+
note: `endpoint ${endpoint}`
|
|
1196
|
+
})),
|
|
1197
|
+
remediation: "Document each endpoint in the README; every host here is a data-flow decision you own.",
|
|
1198
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/egress.outbound-endpoints.md"]
|
|
1199
|
+
})];
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
const SECRET_CAPS = /* @__PURE__ */ new Set([
|
|
1203
|
+
"env",
|
|
1204
|
+
"homedir",
|
|
1205
|
+
"secret-read"
|
|
1206
|
+
]);
|
|
1207
|
+
const egressRules = [outboundEndpoints, {
|
|
1208
|
+
id: "egress.secret-adjacent",
|
|
1209
|
+
title: "Network calls reachable from code that reads secrets",
|
|
1210
|
+
defaultSeverity: "high",
|
|
1211
|
+
defaultConfidence: "low",
|
|
1212
|
+
check({ analysis }) {
|
|
1213
|
+
const secretFiles = new Set(analysis.capUses.filter((u) => SECRET_CAPS.has(u.cap)).map((u) => u.file));
|
|
1214
|
+
const evidence = [];
|
|
1215
|
+
let sameFileLiteral = false;
|
|
1216
|
+
for (const file of [...secretFiles].sort()) {
|
|
1217
|
+
const reads = analysis.capUses.filter((u) => SECRET_CAPS.has(u.cap) && u.file === file);
|
|
1218
|
+
const netScope = [file, ...reachableFrom(analysis, file)];
|
|
1219
|
+
const nets = analysis.netUses.filter((u) => netScope.includes(u.file));
|
|
1220
|
+
for (const net of nets) {
|
|
1221
|
+
if (net.file === file && net.literals.length > 0) sameFileLiteral = true;
|
|
1222
|
+
for (const read of reads.slice(0, 2)) evidence.push({
|
|
1223
|
+
file,
|
|
1224
|
+
line: read.line,
|
|
1225
|
+
snippet: read.snippet,
|
|
1226
|
+
note: `reads ${read.cap === "env" ? "process.env" : read.cap === "homedir" ? "the user home directory" : "a credential-like file"}; ${net.api} in ${net.file}:${net.line} is statically reachable from here`
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (evidence.length === 0) return [];
|
|
1231
|
+
return [finding(this, {
|
|
1232
|
+
confidence: sameFileLiteral ? "medium" : "low",
|
|
1233
|
+
evidence: capEvidence(evidence, 6),
|
|
1234
|
+
remediation: "Keep secret reads and network clients in separate modules, or document the intended data flow for each endpoint.",
|
|
1235
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/egress.secret-adjacent.md"]
|
|
1236
|
+
})];
|
|
1237
|
+
}
|
|
1238
|
+
}];
|
|
1239
|
+
const obfRules = [
|
|
1240
|
+
{
|
|
1241
|
+
id: "obf.eval-detect",
|
|
1242
|
+
title: "Evaluates dynamically built code",
|
|
1243
|
+
defaultSeverity: "medium",
|
|
1244
|
+
defaultConfidence: "medium",
|
|
1245
|
+
check({ analysis }) {
|
|
1246
|
+
const uses = analysis.evalUses;
|
|
1247
|
+
if (uses.length === 0) return [];
|
|
1248
|
+
const nonLiteral = uses.filter((use) => !use.literal);
|
|
1249
|
+
const literal = uses.filter((use) => use.literal);
|
|
1250
|
+
const findings = [];
|
|
1251
|
+
if (nonLiteral.length > 0) findings.push(finding(this, {
|
|
1252
|
+
evidence: capEvidence(nonLiteral.map((use) => ({
|
|
1253
|
+
file: use.file,
|
|
1254
|
+
line: use.line,
|
|
1255
|
+
snippet: use.snippet,
|
|
1256
|
+
note: `${use.kind} of a non-literal argument`
|
|
1257
|
+
}))),
|
|
1258
|
+
remediation: "Replace eval/new Function with direct code; dynamic evaluation defeats static audit.",
|
|
1259
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.eval-detect.md"]
|
|
1260
|
+
}));
|
|
1261
|
+
if (literal.length > 0) findings.push(finding(this, {
|
|
1262
|
+
title: "Evaluates a literal string as code",
|
|
1263
|
+
severity: "info",
|
|
1264
|
+
confidence: "high",
|
|
1265
|
+
evidence: literal.map((use) => ({
|
|
1266
|
+
file: use.file,
|
|
1267
|
+
line: use.line,
|
|
1268
|
+
snippet: use.snippet,
|
|
1269
|
+
note: `${use.kind} of a constant string — inert but still eval`
|
|
1270
|
+
})),
|
|
1271
|
+
remediation: "Move the code out of the string literal.",
|
|
1272
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.eval-detect.md"]
|
|
1273
|
+
}));
|
|
1274
|
+
return findings;
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
id: "obf.dynamic-require",
|
|
1279
|
+
title: "Loads modules through a computed specifier",
|
|
1280
|
+
defaultSeverity: "medium",
|
|
1281
|
+
defaultConfidence: "low",
|
|
1282
|
+
check({ analysis }) {
|
|
1283
|
+
const recoverable = analysis.dynamicImports.filter((use) => use.literals !== null);
|
|
1284
|
+
const opaque = analysis.dynamicImports.filter((use) => use.literals === null);
|
|
1285
|
+
const findings = [];
|
|
1286
|
+
if (recoverable.length > 0) findings.push(finding(this, {
|
|
1287
|
+
confidence: "medium",
|
|
1288
|
+
evidence: recoverable.map((use) => ({
|
|
1289
|
+
file: use.file,
|
|
1290
|
+
line: use.line,
|
|
1291
|
+
snippet: use.snippet,
|
|
1292
|
+
note: `${use.kind} of a concatenation that statically resolves to "${use.literals[0]}"`
|
|
1293
|
+
})),
|
|
1294
|
+
remediation: "Use the plain specifier; concatenated module names hide the dependency from review.",
|
|
1295
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.dynamic-require.md"]
|
|
1296
|
+
}));
|
|
1297
|
+
if (opaque.length > 0) findings.push(finding(this, {
|
|
1298
|
+
evidence: capEvidence(opaque.map((use) => ({
|
|
1299
|
+
file: use.file,
|
|
1300
|
+
line: use.line,
|
|
1301
|
+
snippet: use.snippet,
|
|
1302
|
+
note: `${use.kind} of a runtime value — the loaded module cannot be determined statically`
|
|
1303
|
+
}))),
|
|
1304
|
+
remediation: "Use plain specifiers so the module graph stays auditable.",
|
|
1305
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.dynamic-require.md"]
|
|
1306
|
+
}));
|
|
1307
|
+
return findings;
|
|
1308
|
+
}
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
id: "obf.encoded-payload",
|
|
1312
|
+
title: "Long base64/hex string literals in shipped code",
|
|
1313
|
+
defaultSeverity: "medium",
|
|
1314
|
+
defaultConfidence: "low",
|
|
1315
|
+
check({ analysis }) {
|
|
1316
|
+
const literals = analysis.encodedLiterals;
|
|
1317
|
+
if (literals.length === 0) return [];
|
|
1318
|
+
return [finding(this, {
|
|
1319
|
+
evidence: capEvidence(literals.map((lit) => ({
|
|
1320
|
+
file: lit.file,
|
|
1321
|
+
line: lit.line,
|
|
1322
|
+
snippet: lit.value.slice(0, 80),
|
|
1323
|
+
note: `${lit.charset} literal, ${lit.value.length} chars`
|
|
1324
|
+
}))),
|
|
1325
|
+
remediation: "Decode payloads to plain assets, or ship them as files a reviewer can open.",
|
|
1326
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.encoded-payload.md"]
|
|
1327
|
+
})];
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
{
|
|
1331
|
+
id: "obf.charcode-chain",
|
|
1332
|
+
title: "Builds strings from character codes",
|
|
1333
|
+
defaultSeverity: "medium",
|
|
1334
|
+
defaultConfidence: "medium",
|
|
1335
|
+
check({ analysis }) {
|
|
1336
|
+
const calls = analysis.charcodeCalls;
|
|
1337
|
+
if (calls.length === 0) return [];
|
|
1338
|
+
return [finding(this, {
|
|
1339
|
+
evidence: calls.map((call) => ({
|
|
1340
|
+
file: call.file,
|
|
1341
|
+
line: call.line,
|
|
1342
|
+
note: `decodes to "${call.chars}…"`
|
|
1343
|
+
})),
|
|
1344
|
+
remediation: "Write the string literal directly.",
|
|
1345
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.charcode-chain.md"]
|
|
1346
|
+
})];
|
|
1347
|
+
}
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
id: "obf.unparseable",
|
|
1351
|
+
title: "Shipped JS files that no standard parser accepts",
|
|
1352
|
+
defaultSeverity: "medium",
|
|
1353
|
+
defaultConfidence: "high",
|
|
1354
|
+
check({ analysis }) {
|
|
1355
|
+
const broken = analysis.files.filter((f) => f.parseError !== null);
|
|
1356
|
+
if (broken.length === 0) return [];
|
|
1357
|
+
return [finding(this, {
|
|
1358
|
+
evidence: broken.slice(0, 10).map((f) => ({
|
|
1359
|
+
file: f.path,
|
|
1360
|
+
note: `parse error: ${f.parseError?.slice(0, 120)}`
|
|
1361
|
+
})),
|
|
1362
|
+
remediation: "Ship parseable source, or source maps that let tooling see the real code.",
|
|
1363
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/obf.unparseable.md"]
|
|
1364
|
+
})];
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
];
|
|
1368
|
+
//#endregion
|
|
1369
|
+
//#region src/rules/perm.ts
|
|
1370
|
+
/**
|
|
1371
|
+
* `perm.*` — capability surface vs declared seams (ROADMAP T3).
|
|
1372
|
+
*
|
|
1373
|
+
* Seams are declared in package.json `dsh.seams` (e.g. `["fs","web"]`). The
|
|
1374
|
+
* vocabulary maps detected capabilities to seams: fs/fs-write → `fs`,
|
|
1375
|
+
* child_process → `shell`, net → `web`, worker_threads → `workers`. When no
|
|
1376
|
+
* declaration exists the mismatch rule stays silent — only the per-capability
|
|
1377
|
+
* rules speak, conservatively.
|
|
1378
|
+
*/
|
|
1379
|
+
const SEAM_FOR_CAP = {
|
|
1380
|
+
fs: "fs",
|
|
1381
|
+
"fs-write": "fs",
|
|
1382
|
+
shell: "shell",
|
|
1383
|
+
net: "web",
|
|
1384
|
+
workers: "workers"
|
|
1385
|
+
};
|
|
1386
|
+
const seamMismatch = {
|
|
1387
|
+
id: "perm.seam-mismatch",
|
|
1388
|
+
title: "Capability used but not declared in dsh.seams",
|
|
1389
|
+
defaultSeverity: "medium",
|
|
1390
|
+
defaultConfidence: "medium",
|
|
1391
|
+
check({ analysis }) {
|
|
1392
|
+
const declared = analysis.pkg?.seams;
|
|
1393
|
+
if (!declared) return [];
|
|
1394
|
+
const lower = new Set(declared.map((s) => s.toLowerCase()));
|
|
1395
|
+
const evidence = [];
|
|
1396
|
+
for (const [cap, seam] of Object.entries(SEAM_FOR_CAP)) {
|
|
1397
|
+
if (lower.has(seam)) continue;
|
|
1398
|
+
const use = usesOfCap(analysis, cap)[0];
|
|
1399
|
+
if (!use) continue;
|
|
1400
|
+
evidence.push({
|
|
1401
|
+
file: use.file,
|
|
1402
|
+
line: use.line,
|
|
1403
|
+
snippet: use.snippet,
|
|
1404
|
+
note: `${cap} capability used; seam '${seam}' is not among the declared [${declared.join(", ")}]`
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
if (evidence.length === 0) return [];
|
|
1408
|
+
return [finding(this, {
|
|
1409
|
+
evidence,
|
|
1410
|
+
remediation: "Add the seam to dsh.seams, or drop the capability if it is not needed.",
|
|
1411
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.seam-mismatch.md"]
|
|
1412
|
+
})];
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
const DESTRUCTIVE_FS = /* @__PURE__ */ new Set([
|
|
1416
|
+
"fs.rm",
|
|
1417
|
+
"fs.rmSync",
|
|
1418
|
+
"fs.unlink",
|
|
1419
|
+
"fs.unlinkSync",
|
|
1420
|
+
"fs.rmdir",
|
|
1421
|
+
"fs.rmdirSync",
|
|
1422
|
+
"fs.truncate",
|
|
1423
|
+
"fs.truncateSync",
|
|
1424
|
+
"fs/promises.rm",
|
|
1425
|
+
"fs/promises.unlink",
|
|
1426
|
+
"fs/promises.rmdir",
|
|
1427
|
+
"fs/promises.truncate"
|
|
1428
|
+
]);
|
|
1429
|
+
/** Literal absolute paths that count as inside a plugin's plausible scope. */
|
|
1430
|
+
function inScopeLiteral(path) {
|
|
1431
|
+
return [
|
|
1432
|
+
tmpdir(),
|
|
1433
|
+
`${homedir()}/.dsh`,
|
|
1434
|
+
`${homedir()}/.config/dsh`,
|
|
1435
|
+
`${homedir()}/.cache/dsh`
|
|
1436
|
+
].some((root) => path === root || path.startsWith(`${root}/`));
|
|
1437
|
+
}
|
|
1438
|
+
const undeclaredFsWrite = {
|
|
1439
|
+
id: "perm.undeclared-fs-write",
|
|
1440
|
+
title: "Writes or deletes files outside any plausible plugin scope",
|
|
1441
|
+
defaultSeverity: "high",
|
|
1442
|
+
defaultConfidence: "medium",
|
|
1443
|
+
check({ analysis }) {
|
|
1444
|
+
const uses = usesOfCap(analysis, "fs-write");
|
|
1445
|
+
if (uses.length === 0) return [];
|
|
1446
|
+
const outOfScope = uses.filter((use) => use.literals.some((l) => l.startsWith("/") && !inScopeLiteral(l)));
|
|
1447
|
+
const dynamic = uses.filter((use) => use.literals.length === 0);
|
|
1448
|
+
const findings = [];
|
|
1449
|
+
if (outOfScope.length > 0) {
|
|
1450
|
+
const destructive = outOfScope.some((use) => DESTRUCTIVE_FS.has(use.api));
|
|
1451
|
+
findings.push(finding(this, {
|
|
1452
|
+
title: destructive ? "Destructive filesystem operations outside any plausible plugin scope" : this.title,
|
|
1453
|
+
severity: destructive ? "critical" : "high",
|
|
1454
|
+
confidence: "high",
|
|
1455
|
+
evidence: capEvidence(outOfScope.map((use) => ({
|
|
1456
|
+
file: use.file,
|
|
1457
|
+
line: use.line,
|
|
1458
|
+
snippet: use.snippet,
|
|
1459
|
+
note: `${use.api} with literal path ${use.literals.find((l) => l.startsWith("/") && !inScopeLiteral(l))}`
|
|
1460
|
+
}))),
|
|
1461
|
+
remediation: "Keep writes inside the workspace or the DSH home directory; derive paths from config, not literals.",
|
|
1462
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.undeclared-fs-write.md"]
|
|
1463
|
+
}));
|
|
1464
|
+
}
|
|
1465
|
+
if (dynamic.length > 0) findings.push(finding(this, {
|
|
1466
|
+
severity: "medium",
|
|
1467
|
+
confidence: "low",
|
|
1468
|
+
evidence: capEvidence(dynamic.map((use) => ({
|
|
1469
|
+
file: use.file,
|
|
1470
|
+
line: use.line,
|
|
1471
|
+
snippet: use.snippet
|
|
1472
|
+
})), 5),
|
|
1473
|
+
remediation: "Use fixed, reviewable paths; runtime-computed write targets cannot be audited statically.",
|
|
1474
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.undeclared-fs-write.md"]
|
|
1475
|
+
}));
|
|
1476
|
+
return findings;
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
const NET_IMPORT_RE = /^(?:node:)?(?:net|http|https|http2|tls|dgram|dns|undici)$/;
|
|
1480
|
+
const permRules = [
|
|
1481
|
+
seamMismatch,
|
|
1482
|
+
undeclaredFsWrite,
|
|
1483
|
+
{
|
|
1484
|
+
id: "perm.subprocess-spawn",
|
|
1485
|
+
title: "Spawns subprocesses",
|
|
1486
|
+
defaultSeverity: "medium",
|
|
1487
|
+
defaultConfidence: "high",
|
|
1488
|
+
check({ analysis }) {
|
|
1489
|
+
const declared = declaresSeam(analysis, "shell");
|
|
1490
|
+
const uses = usesOfCap(analysis, "shell");
|
|
1491
|
+
const findings = [];
|
|
1492
|
+
if (uses.length > 0) findings.push(finding(this, {
|
|
1493
|
+
severity: declared === true ? "info" : "medium",
|
|
1494
|
+
confidence: "high",
|
|
1495
|
+
evidence: capEvidence(uses.map((use) => ({
|
|
1496
|
+
file: use.file,
|
|
1497
|
+
line: use.line,
|
|
1498
|
+
snippet: use.snippet,
|
|
1499
|
+
note: declared === true ? "seam \"shell\" is declared" : "no \"shell\" seam declared"
|
|
1500
|
+
})), 5),
|
|
1501
|
+
remediation: "Declare the shell seam in dsh.seams, or avoid spawning processes.",
|
|
1502
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.subprocess-spawn.md"]
|
|
1503
|
+
}));
|
|
1504
|
+
const importOnly = analysis.files.filter((f) => f.externals.some((e) => /^(?:node:)?child_process$/.test(e)) && !uses.some((u) => u.file === f.path));
|
|
1505
|
+
if (importOnly.length > 0) findings.push(finding(this, {
|
|
1506
|
+
title: "Imports child_process without spawning anything",
|
|
1507
|
+
severity: "info",
|
|
1508
|
+
confidence: "high",
|
|
1509
|
+
evidence: importOnly.slice(0, 5).map((f) => ({
|
|
1510
|
+
file: f.path,
|
|
1511
|
+
note: "import present, no spawn/exec call site found"
|
|
1512
|
+
})),
|
|
1513
|
+
remediation: "Remove the unused import.",
|
|
1514
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.subprocess-spawn.md"]
|
|
1515
|
+
}));
|
|
1516
|
+
return findings;
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
id: "perm.network-client",
|
|
1521
|
+
title: "Opens network connections",
|
|
1522
|
+
defaultSeverity: "medium",
|
|
1523
|
+
defaultConfidence: "high",
|
|
1524
|
+
check({ analysis }) {
|
|
1525
|
+
const declared = declaresSeam(analysis, "web");
|
|
1526
|
+
const findings = [];
|
|
1527
|
+
if (analysis.netUses.length > 0) findings.push(finding(this, {
|
|
1528
|
+
severity: declared === true ? "info" : "medium",
|
|
1529
|
+
confidence: "high",
|
|
1530
|
+
evidence: capEvidence(analysis.netUses.map((use) => ({
|
|
1531
|
+
file: use.file,
|
|
1532
|
+
line: use.line,
|
|
1533
|
+
snippet: use.snippet,
|
|
1534
|
+
note: declared === true ? "seam \"web\" is declared" : `no "web" seam declared (${use.literals.join(", ") || "target not statically known"})`
|
|
1535
|
+
})), 5),
|
|
1536
|
+
remediation: "Declare the web seam in dsh.seams, or drop the network access.",
|
|
1537
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.network-client.md"]
|
|
1538
|
+
}));
|
|
1539
|
+
const importOnly = analysis.files.filter((f) => f.externals.some((e) => NET_IMPORT_RE.test(e)) && !analysis.netUses.some((u) => u.file === f.path));
|
|
1540
|
+
if (importOnly.length > 0) findings.push(finding(this, {
|
|
1541
|
+
title: "Imports network modules without an observable client call",
|
|
1542
|
+
severity: "info",
|
|
1543
|
+
confidence: "high",
|
|
1544
|
+
evidence: importOnly.slice(0, 5).map((f) => ({
|
|
1545
|
+
file: f.path,
|
|
1546
|
+
note: "import present, no client call site"
|
|
1547
|
+
})),
|
|
1548
|
+
remediation: "Remove the unused import.",
|
|
1549
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.network-client.md"]
|
|
1550
|
+
}));
|
|
1551
|
+
return findings;
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
{
|
|
1555
|
+
id: "perm.unreachable-files",
|
|
1556
|
+
title: "Shipped files not reachable from any declared entry point",
|
|
1557
|
+
defaultSeverity: "info",
|
|
1558
|
+
defaultConfidence: "high",
|
|
1559
|
+
check({ analysis }) {
|
|
1560
|
+
if (analysis.unreachable.length === 0) return [];
|
|
1561
|
+
return [finding(this, {
|
|
1562
|
+
evidence: analysis.unreachable.slice(0, 10).map((path) => ({
|
|
1563
|
+
file: path,
|
|
1564
|
+
note: "no static import path from any entry point reaches this file"
|
|
1565
|
+
})),
|
|
1566
|
+
remediation: "Dead files still ship and can be required dynamically; delete them or wire them into the graph.",
|
|
1567
|
+
references: ["https://github.com/rogerdigital/dsh-vet/blob/main/docs/rules/perm.unreachable-files.md"]
|
|
1568
|
+
})];
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
];
|
|
1572
|
+
//#endregion
|
|
1573
|
+
//#region src/rules/index.ts
|
|
1574
|
+
const RULES = [
|
|
1575
|
+
...depRules,
|
|
1576
|
+
...egressRules,
|
|
1577
|
+
...obfRules,
|
|
1578
|
+
...permRules
|
|
1579
|
+
].sort((a, b) => a.id.localeCompare(b.id));
|
|
1580
|
+
function ruleIds() {
|
|
1581
|
+
return RULES.map((rule) => rule.id);
|
|
1582
|
+
}
|
|
1583
|
+
/** Run all rules, or the subset named in `only` (unknown ids throw). */
|
|
1584
|
+
function runRules(analysis, only) {
|
|
1585
|
+
let selected = RULES;
|
|
1586
|
+
if (only && only.length > 0) {
|
|
1587
|
+
const known = new Set(RULES.map((r) => r.id));
|
|
1588
|
+
const unknown = only.filter((id) => !known.has(id));
|
|
1589
|
+
if (unknown.length > 0) throw new Error(`unknown rule id(s): ${unknown.join(", ")}`);
|
|
1590
|
+
selected = RULES.filter((rule) => only.includes(rule.id));
|
|
1591
|
+
}
|
|
1592
|
+
const findings = [];
|
|
1593
|
+
for (const rule of selected) findings.push(...rule.check({ analysis }));
|
|
1594
|
+
return findings;
|
|
1595
|
+
}
|
|
1596
|
+
//#endregion
|
|
1597
|
+
//#region src/scanner.ts
|
|
1598
|
+
/**
|
|
1599
|
+
* Scanner orchestration: resolve → analyze → rules → report. The only ambient
|
|
1600
|
+
* state in a report is `scanner.ranAt`; everything else is derived, so two runs
|
|
1601
|
+
* over the same artifact with the same version are identical.
|
|
1602
|
+
*/
|
|
1603
|
+
/** Kept in lockstep with package.json; a test asserts they match. */
|
|
1604
|
+
const SCANNER_VERSION = "0.1.0";
|
|
1605
|
+
async function scanDirectory(dir, options = {}) {
|
|
1606
|
+
const analysis = analyze(dir);
|
|
1607
|
+
return createReport({
|
|
1608
|
+
target: {
|
|
1609
|
+
kind: "local-path",
|
|
1610
|
+
specifier: dir
|
|
1611
|
+
},
|
|
1612
|
+
scanner: {
|
|
1613
|
+
name: "dsh-vet",
|
|
1614
|
+
version: SCANNER_VERSION,
|
|
1615
|
+
ranAt: options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1616
|
+
},
|
|
1617
|
+
findings: options.rules ? runRules(analysis, options.rules) : runRules(analysis)
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
async function scan(specifier, options = {}) {
|
|
1621
|
+
const resolved = await resolveTarget(specifier, options);
|
|
1622
|
+
try {
|
|
1623
|
+
const analysis = analyze(resolved.rootDir);
|
|
1624
|
+
return createReport({
|
|
1625
|
+
target: resolved.target,
|
|
1626
|
+
scanner: {
|
|
1627
|
+
name: "dsh-vet",
|
|
1628
|
+
version: SCANNER_VERSION,
|
|
1629
|
+
ranAt: options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
1630
|
+
},
|
|
1631
|
+
findings: options.rules ? runRules(analysis, options.rules) : runRules(analysis)
|
|
1632
|
+
});
|
|
1633
|
+
} finally {
|
|
1634
|
+
resolved.cleanup();
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
//#endregion
|
|
1638
|
+
//#region src/cli.ts
|
|
1639
|
+
/**
|
|
1640
|
+
* CLI (ROADMAP T4). Exit semantics per the dsh-vet/v1 spec: `0` for any
|
|
1641
|
+
* completed report (even a graded-F one), non-zero for scanner failure;
|
|
1642
|
+
* `--strict`/`--fail-on` turn threshold breaches into exit code 1.
|
|
1643
|
+
*/
|
|
1644
|
+
const USAGE = `usage: dsh-vet <specifier> [options]
|
|
1645
|
+
|
|
1646
|
+
specifier npm package (name[@version]), git URL, or local path
|
|
1647
|
+
|
|
1648
|
+
--json emit a dsh-vet/v1 report as JSON
|
|
1649
|
+
--strict exit 1 when findings >= high with confidence >= medium
|
|
1650
|
+
--fail-on <sev> override the --strict threshold (critical|high|medium|low)
|
|
1651
|
+
--rules <ids> comma-separated rule ids to run
|
|
1652
|
+
--version print version
|
|
1653
|
+
--help this text`;
|
|
1654
|
+
const SEVERITY_ORDER = [
|
|
1655
|
+
"critical",
|
|
1656
|
+
"high",
|
|
1657
|
+
"medium",
|
|
1658
|
+
"low",
|
|
1659
|
+
"info"
|
|
1660
|
+
];
|
|
1661
|
+
function thresholdBreach(report, threshold) {
|
|
1662
|
+
const limit = SEVERITY_ORDER.indexOf(threshold);
|
|
1663
|
+
return report.findings.some((f) => isGraded(f) && SEVERITY_ORDER.indexOf(f.severity) <= limit);
|
|
1664
|
+
}
|
|
1665
|
+
function humanSummary(report) {
|
|
1666
|
+
const lines = [];
|
|
1667
|
+
const resolved = report.target.resolved;
|
|
1668
|
+
const what = report.target.kind === "npm-package" ? `${report.target.specifier}${resolved?.version ? ` (resolved ${resolved.version})` : ""}` : report.target.specifier;
|
|
1669
|
+
lines.push(`dsh-vet ${SCANNER_VERSION} · ${report.target.kind} · ${what}`);
|
|
1670
|
+
lines.push(`grade: ${report.summary.grade}`);
|
|
1671
|
+
const c = report.summary.counts;
|
|
1672
|
+
lines.push(`findings: ${c.critical} critical · ${c.high} high · ${c.medium} medium · ${c.low} low · ${c.info} info`);
|
|
1673
|
+
if (report.findings.length > 0) lines.push("");
|
|
1674
|
+
for (const f of report.findings) {
|
|
1675
|
+
lines.push(`[${f.severity[0].toUpperCase()}] ${f.id} — ${f.title} (${f.confidence} confidence)`);
|
|
1676
|
+
for (const e of f.evidence) {
|
|
1677
|
+
const where = e.line ? `${e.file}:${e.line}` : e.file;
|
|
1678
|
+
lines.push(` ${where}${e.snippet ? ` ${e.snippet}` : ""}`);
|
|
1679
|
+
if (e.note) lines.push(` ${e.note}`);
|
|
1680
|
+
}
|
|
1681
|
+
if (f.remediation) lines.push(` fix: ${f.remediation}`);
|
|
1682
|
+
lines.push("");
|
|
1683
|
+
}
|
|
1684
|
+
return lines.join("\n");
|
|
1685
|
+
}
|
|
1686
|
+
/** Parse and run; returns the process exit code. */
|
|
1687
|
+
async function runCli(argv, io) {
|
|
1688
|
+
let args;
|
|
1689
|
+
try {
|
|
1690
|
+
args = parseArgs({
|
|
1691
|
+
args: argv,
|
|
1692
|
+
options: {
|
|
1693
|
+
json: { type: "boolean" },
|
|
1694
|
+
strict: { type: "boolean" },
|
|
1695
|
+
"fail-on": { type: "string" },
|
|
1696
|
+
rules: { type: "string" },
|
|
1697
|
+
version: { type: "boolean" },
|
|
1698
|
+
help: { type: "boolean" }
|
|
1699
|
+
},
|
|
1700
|
+
allowPositionals: true
|
|
1701
|
+
});
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
io.stderr(`${err.message}\n\n${USAGE}`);
|
|
1704
|
+
return 2;
|
|
1705
|
+
}
|
|
1706
|
+
if (args.values.version) {
|
|
1707
|
+
io.stdout(SCANNER_VERSION);
|
|
1708
|
+
return 0;
|
|
1709
|
+
}
|
|
1710
|
+
if (args.values.help) {
|
|
1711
|
+
io.stdout(USAGE);
|
|
1712
|
+
return 0;
|
|
1713
|
+
}
|
|
1714
|
+
const specifier = args.positionals[0];
|
|
1715
|
+
if (!specifier || args.positionals.length > 1) {
|
|
1716
|
+
io.stderr(`expected exactly one specifier\n\n${USAGE}`);
|
|
1717
|
+
return 2;
|
|
1718
|
+
}
|
|
1719
|
+
const failOn = args.values["fail-on"] ?? (args.values.strict ? "high" : null);
|
|
1720
|
+
if (failOn !== null && !SEVERITY_ORDER.includes(failOn)) {
|
|
1721
|
+
io.stderr(`invalid --fail-on severity: ${failOn}\n\n${USAGE}`);
|
|
1722
|
+
return 2;
|
|
1723
|
+
}
|
|
1724
|
+
const rulesArg = args.values.rules;
|
|
1725
|
+
const rules = typeof rulesArg === "string" ? rulesArg.split(",").map((r) => r.trim()).filter(Boolean) : void 0;
|
|
1726
|
+
try {
|
|
1727
|
+
const report = classifySpecifier(specifier) === "local-path" && existsSync(specifier) ? await scanDirectory(specifier, { rules }) : await scan(specifier, { rules });
|
|
1728
|
+
if (args.values.json) io.stdout(JSON.stringify(report, null, 2));
|
|
1729
|
+
else io.stdout(humanSummary(report));
|
|
1730
|
+
if (failOn !== null && thresholdBreach(report, failOn)) {
|
|
1731
|
+
io.stderr(`threshold breached: findings at or above ${failOn} (confidence >= medium)`);
|
|
1732
|
+
return 1;
|
|
1733
|
+
}
|
|
1734
|
+
return 0;
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
io.stderr(`dsh-vet: ${err.message}`);
|
|
1737
|
+
return 2;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
//#endregion
|
|
1741
|
+
export { RULES, RULE_ID_PATTERN, SCANNER_VERSION, SCHEMA_ID, VetError, analyze, classifySpecifier, countFindings, createReport, gradeFor, isGraded, parseNpmSpecifier, reachableFrom, resolveTarget, ruleIds, runCli, runRules, scan, scanDirectory };
|