dsh-plugin-inspector 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 +271 -0
- package/lib/checks/input.js +10 -0
- package/lib/checks/tier-a.js +632 -0
- package/lib/checks/tier-b.js +411 -0
- package/lib/checks/tier-c.js +288 -0
- package/lib/cli.js +154 -0
- package/lib/cordis-yaml.js +393 -0
- package/lib/files.js +131 -0
- package/lib/index.js +22 -0
- package/lib/injection.js +90 -0
- package/lib/inspect.js +168 -0
- package/lib/knowledge.js +321 -0
- package/lib/manifest.js +143 -0
- package/lib/model.js +55 -0
- package/lib/publish.js +208 -0
- package/lib/report.js +182 -0
- package/lib/source.js +410 -0
- package/lib/types/checks/input.d.ts +42 -0
- package/lib/types/checks/tier-a.d.ts +24 -0
- package/lib/types/checks/tier-b.d.ts +23 -0
- package/lib/types/checks/tier-c.d.ts +37 -0
- package/lib/types/cli.d.ts +56 -0
- package/lib/types/cordis-yaml.d.ts +133 -0
- package/lib/types/files.d.ts +71 -0
- package/lib/types/index.d.ts +23 -0
- package/lib/types/injection.d.ts +41 -0
- package/lib/types/inspect.d.ts +31 -0
- package/lib/types/knowledge.d.ts +137 -0
- package/lib/types/manifest.d.ts +62 -0
- package/lib/types/model.d.ts +160 -0
- package/lib/types/publish.d.ts +55 -0
- package/lib/types/report.d.ts +27 -0
- package/lib/types/source.d.ts +71 -0
- package/package.json +61 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier C — how much of the package the analyzer could actually read.
|
|
3
|
+
*
|
|
4
|
+
* These checks do not describe the plugin's behavior. They describe the limits
|
|
5
|
+
* of the analysis, and that is why a Tier C hit is a finding rather than a
|
|
6
|
+
* silent internal flag: "we cannot read this" is a legitimate result and the
|
|
7
|
+
* user is entitled to see it.
|
|
8
|
+
*
|
|
9
|
+
* A Tier C hit also has a mechanical consequence. Tier B recognises a whitelist
|
|
10
|
+
* of syntactic shapes, so when code is minified, when identifiers are computed,
|
|
11
|
+
* or when the shipped artifact has no readable source, a Tier B *positive* is
|
|
12
|
+
* still true but a Tier B *negative* means nothing. `inspect.ts` reads the
|
|
13
|
+
* output of this module to lower Tier B confidence and to forbid the report
|
|
14
|
+
* from claiming nothing was found.
|
|
15
|
+
* @module dsh-plugin-inspector/checks/tier-c
|
|
16
|
+
*/
|
|
17
|
+
import ts from 'typescript';
|
|
18
|
+
import { lineColumn, snippet } from "../files.js";
|
|
19
|
+
/** A line longer than this is not written by hand. */
|
|
20
|
+
const MINIFIED_LINE_LENGTH = 500;
|
|
21
|
+
/** Below this many bytes, a low line count says nothing. */
|
|
22
|
+
const MINIFICATION_SIZE_FLOOR = 4096;
|
|
23
|
+
/** Object names whose computed member access is a dispatch, not an array index. */
|
|
24
|
+
const DISPATCH_RECEIVERS = new Set(['ctx', 'context', 'globalThis', 'global']);
|
|
25
|
+
/** Members whose first argument names a seam, an event, or a tool. */
|
|
26
|
+
const NAMED_TARGET_CALLEES = new Set([
|
|
27
|
+
'on', 'once', 'provide', 'set', 'get', 'emit', 'waterfall', 'bail', 'parallel', 'serial',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Build one Tier C finding. Confidence is `moderate`: these are heuristics
|
|
31
|
+
* about form, and a hand-written file can legitimately have one long line.
|
|
32
|
+
* @param finding - everything but the fixed fields.
|
|
33
|
+
* @returns the complete finding.
|
|
34
|
+
*/
|
|
35
|
+
function tierC(finding) {
|
|
36
|
+
return { ...finding, tier: 'C', confidence: 'moderate' };
|
|
37
|
+
}
|
|
38
|
+
/** C1 — source that is not written to be read. */
|
|
39
|
+
function checkMinification(input) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const path of input.sourceFiles) {
|
|
42
|
+
const text = input.source.files.get(path);
|
|
43
|
+
if (text === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
const lines = text.split('\n');
|
|
46
|
+
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
47
|
+
const dense = text.length >= MINIFICATION_SIZE_FLOOR && lines.length < 5;
|
|
48
|
+
// One long line does not make a file unreadable — an embedded prompt or a
|
|
49
|
+
// base64 asset in otherwise ordinary code is one long line, and the
|
|
50
|
+
// harness's own web bundle has one. What makes a file unreadable is when
|
|
51
|
+
// the long lines are most of it.
|
|
52
|
+
const longBytes = lines.filter(line => line.length >= MINIFIED_LINE_LENGTH)
|
|
53
|
+
.reduce((sum, line) => sum + line.length, 0);
|
|
54
|
+
const dominated = longBytes * 2 >= text.length;
|
|
55
|
+
if (!dense && !dominated)
|
|
56
|
+
continue;
|
|
57
|
+
findings.push(tierC({
|
|
58
|
+
checkId: 'C1',
|
|
59
|
+
name: 'minified-source',
|
|
60
|
+
severity: 'medium',
|
|
61
|
+
title: `\`${path}\` is minified or generated`,
|
|
62
|
+
detail: `Longest line is ${longest} characters across ${lines.length} line(s), and lines that long are `
|
|
63
|
+
+ `${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability detection reads `
|
|
64
|
+
+ 'syntax, and it reads minified syntax no better than a person does. Every Tier B negative for this '
|
|
65
|
+
+ 'package is unreliable while this file is in it.',
|
|
66
|
+
evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
|
|
67
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
/** C2 — names the analyzer cannot resolve without running the code. */
|
|
73
|
+
function checkDynamicDispatch(input) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
for (const path of input.sourceFiles) {
|
|
76
|
+
const text = input.source.files.get(path);
|
|
77
|
+
if (text === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
|
80
|
+
const report = (node, what) => {
|
|
81
|
+
findings.push(tierC({
|
|
82
|
+
checkId: 'C2',
|
|
83
|
+
name: 'dynamic-dispatch',
|
|
84
|
+
severity: 'high',
|
|
85
|
+
title: `\`${path}\` ${what}`,
|
|
86
|
+
detail: 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of them, so no '
|
|
87
|
+
+ 'Tier B negative for this package carries any information. A Tier B positive still does — the tool saw '
|
|
88
|
+
+ 'what it saw.',
|
|
89
|
+
evidence: {
|
|
90
|
+
file: path,
|
|
91
|
+
path: lineColumn(text, node.getStart(source)),
|
|
92
|
+
snippet: snippet(text.slice(node.getStart(source), node.end)),
|
|
93
|
+
},
|
|
94
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
95
|
+
}));
|
|
96
|
+
};
|
|
97
|
+
const isComputed = (node) => node !== undefined && !ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node);
|
|
98
|
+
const visit = (node) => {
|
|
99
|
+
if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)
|
|
100
|
+
&& DISPATCH_RECEIVERS.has(node.expression.text) && isComputed(node.argumentExpression)) {
|
|
101
|
+
report(node, `resolves a member of \`${node.expression.text}\` from a computed name`);
|
|
102
|
+
}
|
|
103
|
+
if (ts.isCallExpression(node)) {
|
|
104
|
+
const callee = node.expression;
|
|
105
|
+
const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
|
|
106
|
+
const isImport = callee.kind === ts.SyntaxKind.ImportKeyword;
|
|
107
|
+
if ((isRequire || isImport) && isComputed(node.arguments[0])) {
|
|
108
|
+
report(node, 'loads a module from a computed specifier');
|
|
109
|
+
}
|
|
110
|
+
if (ts.isIdentifier(callee) && callee.text === 'atob') {
|
|
111
|
+
report(node, 'decodes a base64 string at runtime');
|
|
112
|
+
}
|
|
113
|
+
if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from'
|
|
114
|
+
&& ts.isIdentifier(callee.expression) && callee.expression.text === 'Buffer'
|
|
115
|
+
&& (literalOf(node.arguments[1]) === 'base64' || literalOf(node.arguments[1]) === 'base64url')) {
|
|
116
|
+
report(node, 'decodes a base64 string at runtime');
|
|
117
|
+
}
|
|
118
|
+
if (ts.isPropertyAccessExpression(callee) && NAMED_TARGET_CALLEES.has(callee.name.text)
|
|
119
|
+
&& isDispatchReceiver(callee.expression) && isAssembledName(node.arguments[0])) {
|
|
120
|
+
report(node, `passes an assembled name to \`${receiverName(callee.expression)}.${callee.name.text}()\``);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ts.forEachChild(node, visit);
|
|
124
|
+
};
|
|
125
|
+
ts.forEachChild(source, visit);
|
|
126
|
+
}
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Whether an expression names the plugin context.
|
|
131
|
+
*
|
|
132
|
+
* `.set`, `.get`, `.on` and `.emit` are the plugin API's names and also
|
|
133
|
+
* `Map`'s, `Set`'s, and every EventEmitter's. Without this guard the check
|
|
134
|
+
* reads `this.steps.set(\`${turn}:${step}\`, time)` — an ordinary composite Map
|
|
135
|
+
* key — as evasion, which alone degrades the whole report and makes every
|
|
136
|
+
* Tier B negative unreliable. Element access is already guarded this way; this
|
|
137
|
+
* makes the call form agree with it.
|
|
138
|
+
* @param node - the receiver expression.
|
|
139
|
+
* @returns true when the receiver is a known context binding.
|
|
140
|
+
*/
|
|
141
|
+
function isDispatchReceiver(node) {
|
|
142
|
+
if (ts.isIdentifier(node))
|
|
143
|
+
return DISPATCH_RECEIVERS.has(node.text);
|
|
144
|
+
// `this.ctx.on(…)` and `self.ctx.on(…)` are the same receiver held on a field.
|
|
145
|
+
if (ts.isPropertyAccessExpression(node))
|
|
146
|
+
return DISPATCH_RECEIVERS.has(node.name.text);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The receiver's own name, for the finding's title.
|
|
151
|
+
* @param node - the receiver expression.
|
|
152
|
+
* @returns the identifier or member name.
|
|
153
|
+
*/
|
|
154
|
+
function receiverName(node) {
|
|
155
|
+
if (ts.isIdentifier(node))
|
|
156
|
+
return node.text;
|
|
157
|
+
if (ts.isPropertyAccessExpression(node))
|
|
158
|
+
return node.name.text;
|
|
159
|
+
return '?';
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Whether a node builds a string at runtime rather than naming one. A plain
|
|
163
|
+
* identifier is deliberately excluded: `ctx.on(EVENT_NAME, …)` against a module
|
|
164
|
+
* constant is ordinary code, and treating it as evasion would degrade the
|
|
165
|
+
* analysis of nearly every well-written plugin.
|
|
166
|
+
* @param node - the argument node.
|
|
167
|
+
* @returns true for concatenation, an interpolated template, or a call.
|
|
168
|
+
*/
|
|
169
|
+
function isAssembledName(node) {
|
|
170
|
+
if (node === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
|
|
173
|
+
return true;
|
|
174
|
+
if (ts.isTemplateExpression(node))
|
|
175
|
+
return true;
|
|
176
|
+
return ts.isCallExpression(node);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The literal text of a node, or `null`.
|
|
180
|
+
* @param node - the node.
|
|
181
|
+
* @returns the literal text.
|
|
182
|
+
*/
|
|
183
|
+
function literalOf(node) {
|
|
184
|
+
if (node === undefined)
|
|
185
|
+
return null;
|
|
186
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
187
|
+
return node.text;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Tier C checks that do **not** make a Tier B negative unreliable.
|
|
192
|
+
*
|
|
193
|
+
* Every other check here says the analyzer could not read something. C3 says
|
|
194
|
+
* the opposite: the bytes were read exactly as written and exactly as they will
|
|
195
|
+
* run — what cannot be checked is whether they match the repository that
|
|
196
|
+
* claims to have produced them. That is worth reporting and it is not a reason
|
|
197
|
+
* to distrust the parse, and treating it as one marks every ordinary published
|
|
198
|
+
* tarball `degraded`, because shipping built output and no source is what
|
|
199
|
+
* publishing a package *is*.
|
|
200
|
+
*/
|
|
201
|
+
export const NON_DEGRADING_CHECKS = new Set(['C3']);
|
|
202
|
+
/** C3, C6 — shipped build output with nothing to compare it against. */
|
|
203
|
+
function checkSourcelessBuild(input) {
|
|
204
|
+
const built = input.sourceFiles.filter(path => /^(?:lib|dist|build|out)\//.test(path));
|
|
205
|
+
const authored = input.sourceFiles.filter(path => /^(?:src|source)\//.test(path));
|
|
206
|
+
const minified = input.sourceFiles.filter(path => path.endsWith('.min.js'));
|
|
207
|
+
const findings = [];
|
|
208
|
+
if (built.length > 0 && authored.length === 0) {
|
|
209
|
+
findings.push(tierC({
|
|
210
|
+
checkId: 'C3',
|
|
211
|
+
name: 'sourceless-build-output',
|
|
212
|
+
severity: 'low',
|
|
213
|
+
title: `Ships ${built.length} built file(s) and no source`,
|
|
214
|
+
detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
|
|
215
|
+
+ 'package to check the build against. Whether the source that produced it matches the repository is not '
|
|
216
|
+
+ 'decidable from here.',
|
|
217
|
+
evidence: { file: built[0] ?? '', snippet: snippet(built.slice(0, 5).join(', ')) },
|
|
218
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
for (const path of minified) {
|
|
222
|
+
findings.push(tierC({
|
|
223
|
+
checkId: 'C6',
|
|
224
|
+
name: 'minified-artifact',
|
|
225
|
+
severity: 'low',
|
|
226
|
+
title: `\`${path}\` is a minified artifact`,
|
|
227
|
+
detail: 'A `.min.js` file is output, not source. It was still parsed, but nothing about its readability '
|
|
228
|
+
+ 'supports a confident negative.',
|
|
229
|
+
evidence: { file: path },
|
|
230
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
return findings;
|
|
234
|
+
}
|
|
235
|
+
/** C4 — files the reader refused or could not decode. */
|
|
236
|
+
function checkUnreadableFiles(input) {
|
|
237
|
+
const skipped = input.source.skipped;
|
|
238
|
+
if (skipped.length === 0)
|
|
239
|
+
return [];
|
|
240
|
+
const byReason = new Map();
|
|
241
|
+
for (const entry of skipped) {
|
|
242
|
+
byReason.set(entry.reason, [...byReason.get(entry.reason) ?? [], entry.path]);
|
|
243
|
+
}
|
|
244
|
+
return [...byReason].map(([reason, paths]) => tierC({
|
|
245
|
+
checkId: 'C4',
|
|
246
|
+
name: 'unreadable-payload',
|
|
247
|
+
severity: reason === 'binary' ? 'medium' : 'low',
|
|
248
|
+
title: `${paths.length} file(s) were not analysed (${reason})`,
|
|
249
|
+
detail: reason === 'binary'
|
|
250
|
+
? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
|
|
251
|
+
+ 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
|
|
252
|
+
: 'These files exceeded a size or count cap and were not read. Nothing is claimed about their contents.',
|
|
253
|
+
evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
|
|
254
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
/** C5 — a patch layer whose structure hit a walk ceiling before it was read out. */
|
|
258
|
+
function checkPatchWalkLimit(input) {
|
|
259
|
+
return input.patches.filter(patch => patch.limit !== null).map(patch => tierC({
|
|
260
|
+
checkId: 'C5',
|
|
261
|
+
name: 'patch-walk-truncated',
|
|
262
|
+
severity: 'high',
|
|
263
|
+
title: `\`${patch.file}\` was only read in part (${patch.limit === 'depth' ? 'nesting' : 'node count'} ceiling)`,
|
|
264
|
+
detail: patch.limit === 'depth'
|
|
265
|
+
? 'The layer nests deeper than any composition needs. Everything below that point is unread, so no Tier A '
|
|
266
|
+
+ 'reading of this layer is complete.'
|
|
267
|
+
: 'The layer expands to more nodes than the analyzer will walk. YAML anchors make that cheap to write — a '
|
|
268
|
+
+ 'few hundred bytes of `*alias` references describe a graph with billions of paths through it — and the '
|
|
269
|
+
+ 'usual reason to write one is that a reader gives up before reaching what it hides. Rows past the '
|
|
270
|
+
+ 'ceiling were not read.',
|
|
271
|
+
evidence: { file: patch.file },
|
|
272
|
+
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Run every Tier C check.
|
|
277
|
+
* @param input - the decoded package.
|
|
278
|
+
* @returns findings, unordered.
|
|
279
|
+
*/
|
|
280
|
+
export function runTierC(input) {
|
|
281
|
+
return [
|
|
282
|
+
...checkMinification(input),
|
|
283
|
+
...checkDynamicDispatch(input),
|
|
284
|
+
...checkSourcelessBuild(input),
|
|
285
|
+
...checkUnreadableFiles(input),
|
|
286
|
+
...checkPatchWalkLimit(input),
|
|
287
|
+
];
|
|
288
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-inspect` — the command line face of the inspector.
|
|
4
|
+
*
|
|
5
|
+
* Exit codes are the CI contract and are deliberately three-valued:
|
|
6
|
+
* `0` clean, `1` findings at or above the threshold, `2` the analysis could not
|
|
7
|
+
* be performed. A job that cannot tell "the analyzer broke" from "the plugin is
|
|
8
|
+
* clean" is the failure mode that split exists to prevent.
|
|
9
|
+
* @module dsh-plugin-inspector/cli
|
|
10
|
+
*/
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import { exceedsThreshold, inspect, TOOL_VERSION } from "./inspect.js";
|
|
13
|
+
import { SEVERITY_RANK } from "./model.js";
|
|
14
|
+
import { renderHuman, renderJson } from "./report.js";
|
|
15
|
+
/** Exit codes this tool uses. */
|
|
16
|
+
export const EXIT = {
|
|
17
|
+
clean: 0,
|
|
18
|
+
findings: 1,
|
|
19
|
+
unanalysable: 2,
|
|
20
|
+
};
|
|
21
|
+
const USAGE = `dsh-inspect — know what a DeepSeek Harness plugin does before you install it
|
|
22
|
+
|
|
23
|
+
Usage
|
|
24
|
+
dsh-inspect <target> [options]
|
|
25
|
+
|
|
26
|
+
<target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
|
|
27
|
+
Nothing in the target is installed, built, or executed.
|
|
28
|
+
|
|
29
|
+
Options
|
|
30
|
+
--json Emit the machine-readable JSON document on stdout.
|
|
31
|
+
--fail-on <severity> Exit 1 at or above this severity.
|
|
32
|
+
critical | high | medium | low | none (default: high)
|
|
33
|
+
--no-color Plain text, no ANSI.
|
|
34
|
+
--version Print version.
|
|
35
|
+
--help Print this message.
|
|
36
|
+
|
|
37
|
+
Exit codes
|
|
38
|
+
0 analysis completed, nothing at or above --fail-on
|
|
39
|
+
1 analysis completed, at least one finding at or above --fail-on
|
|
40
|
+
2 analysis could not be performed
|
|
41
|
+
|
|
42
|
+
To inspect a published package without installing it:
|
|
43
|
+
npm pack <name>@<version> --pack-destination /tmp && dsh-inspect /tmp/<name>-<version>.tgz
|
|
44
|
+
`;
|
|
45
|
+
/** Raised for a malformed command line; the message is printed and the tool exits 2. */
|
|
46
|
+
export class UsageError extends Error {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse argv.
|
|
50
|
+
* @param argv - arguments after the node binary and script path.
|
|
51
|
+
* @returns the parsed options, or `null` when usage or version was requested.
|
|
52
|
+
* @throws UsageError on an unrecognised or incomplete argument.
|
|
53
|
+
*/
|
|
54
|
+
export function parseArgs(argv) {
|
|
55
|
+
let target = null;
|
|
56
|
+
let json = false;
|
|
57
|
+
let failOn = 'high';
|
|
58
|
+
let color = process.stdout.isTTY === true;
|
|
59
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
60
|
+
const argument = argv[index] ?? '';
|
|
61
|
+
if (argument === '--help' || argument === '-h') {
|
|
62
|
+
process.stdout.write(USAGE);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (argument === '--version' || argument === '-V') {
|
|
66
|
+
process.stdout.write(`${TOOL_VERSION}\n`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (argument === '--json') {
|
|
70
|
+
json = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (argument === '--no-color') {
|
|
74
|
+
color = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (argument === '--color') {
|
|
78
|
+
color = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (argument === '--fail-on') {
|
|
82
|
+
const value = argv[index + 1];
|
|
83
|
+
index += 1;
|
|
84
|
+
if (value === undefined)
|
|
85
|
+
throw new UsageError('--fail-on needs a severity');
|
|
86
|
+
if (value !== 'none' && !(value in SEVERITY_RANK)) {
|
|
87
|
+
throw new UsageError(`--fail-on must be one of critical, high, medium, low, none — got ${value}`);
|
|
88
|
+
}
|
|
89
|
+
failOn = value;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (argument.startsWith('-'))
|
|
93
|
+
throw new UsageError(`unknown option ${argument}`);
|
|
94
|
+
if (target !== null)
|
|
95
|
+
throw new UsageError('only one target may be inspected at a time');
|
|
96
|
+
target = argument;
|
|
97
|
+
}
|
|
98
|
+
if (target === null)
|
|
99
|
+
throw new UsageError('a target directory or tarball is required');
|
|
100
|
+
return { target, json, failOn, color };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run one invocation.
|
|
104
|
+
* @param argv - arguments after the node binary and script path.
|
|
105
|
+
* @returns the process exit code.
|
|
106
|
+
*/
|
|
107
|
+
export async function main(argv) {
|
|
108
|
+
let options;
|
|
109
|
+
try {
|
|
110
|
+
options = parseArgs(argv);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
|
|
114
|
+
return EXIT.unanalysable;
|
|
115
|
+
}
|
|
116
|
+
if (options === null)
|
|
117
|
+
return EXIT.clean;
|
|
118
|
+
try {
|
|
119
|
+
const report = await inspect(options.target);
|
|
120
|
+
process.stdout.write(options.json ? renderJson(report) : renderHuman(report, options.color));
|
|
121
|
+
return exceedsThreshold(report, options.failOn) ? EXIT.findings : EXIT.clean;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
process.stderr.write(`dsh-inspect: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
125
|
+
return EXIT.unanalysable;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Report a failure that reached no `try`, and say which exit code it is.
|
|
130
|
+
*
|
|
131
|
+
* Not every failure can be caught where it happens. A `RangeError` raised
|
|
132
|
+
* inside a stream's `'end'` handler is thrown at an EventEmitter, not at the
|
|
133
|
+
* `await`, so it walks past every `catch` in this program and kills the process
|
|
134
|
+
* with Node's default handler — which exits **1**, the code that means "the
|
|
135
|
+
* analysis completed and found something at or above --fail-on". A CI job then
|
|
136
|
+
* reads a crash as a verdict. The whole point of a separate code 2 is that this
|
|
137
|
+
* cannot happen, so the last resort has to be covered too.
|
|
138
|
+
* @param error - whatever was thrown.
|
|
139
|
+
* @returns the exit code to leave with.
|
|
140
|
+
*/
|
|
141
|
+
export function reportFatal(error) {
|
|
142
|
+
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
143
|
+
process.stderr.write(`dsh-inspect: the analysis could not be completed: ${message}\n`);
|
|
144
|
+
return EXIT.unanalysable;
|
|
145
|
+
}
|
|
146
|
+
if (import.meta.main) {
|
|
147
|
+
process.on('uncaughtException', (error) => {
|
|
148
|
+
process.exit(reportFatal(error));
|
|
149
|
+
});
|
|
150
|
+
process.on('unhandledRejection', (error) => {
|
|
151
|
+
process.exit(reportFatal(error));
|
|
152
|
+
});
|
|
153
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
154
|
+
}
|