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.
@@ -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
+ }