dsh-plugin-inspector 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -54
- package/lib/checks/tier-a.js +28 -2
- package/lib/checks/tier-b.js +62 -5
- package/lib/checks/tier-c.js +14 -8
- package/lib/cli.js +54 -10
- package/lib/files.js +1 -1
- package/lib/index.js +5 -3
- package/lib/inspect.js +22 -6
- package/lib/model.js +0 -0
- package/lib/npm.js +58 -0
- package/lib/registry.js +261 -0
- package/lib/report.js +25 -5
- package/lib/source.js +57 -15
- package/lib/types/cli.d.ts +15 -3
- package/lib/types/files.d.ts +1 -1
- package/lib/types/index.d.ts +5 -3
- package/lib/types/inspect.d.ts +15 -2
- package/lib/types/model.d.ts +85 -7
- package/lib/types/npm.d.ts +40 -0
- package/lib/types/registry.d.ts +119 -0
- package/lib/types/source.d.ts +21 -1
- package/package.json +2 -1
package/lib/checks/tier-b.js
CHANGED
|
@@ -37,7 +37,7 @@ const SYSTEM_PROMPT_MEMBERS = new Set([
|
|
|
37
37
|
* @returns the complete finding.
|
|
38
38
|
*/
|
|
39
39
|
function tierB(finding) {
|
|
40
|
-
return { ...finding, tier: 'B', confidence: 'high' };
|
|
40
|
+
return { ...finding, tier: 'B', confidence: 'high', examples: [finding.evidence], occurrences: 1 };
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* The literal text of a string argument, or `null` when it is computed.
|
|
@@ -110,7 +110,11 @@ function checkImports(file, accumulator) {
|
|
|
110
110
|
accumulator.findings.push(tierB({
|
|
111
111
|
checkId: 'B9',
|
|
112
112
|
name: 'unmediated-process-api',
|
|
113
|
-
|
|
113
|
+
subject: specifier,
|
|
114
|
+
// Raised to `high` by `escalateProcessImports` when this package also
|
|
115
|
+
// reads a credential or reaches the network. On its own it is a
|
|
116
|
+
// capability half the ecosystem has.
|
|
117
|
+
severity: 'medium',
|
|
114
118
|
title: `Imports \`${specifier}\`, which ${unmediated}`,
|
|
115
119
|
detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
|
|
116
120
|
+ 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
|
|
@@ -123,6 +127,7 @@ function checkImports(file, accumulator) {
|
|
|
123
127
|
const finding = tierB({
|
|
124
128
|
checkId: 'B7',
|
|
125
129
|
name: 'network-egress',
|
|
130
|
+
subject: specifier,
|
|
126
131
|
severity: 'medium',
|
|
127
132
|
title: `Imports \`${specifier}\`, which can move bytes off the machine`,
|
|
128
133
|
detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
|
|
@@ -137,6 +142,7 @@ function checkImports(file, accumulator) {
|
|
|
137
142
|
accumulator.findings.push(tierB({
|
|
138
143
|
checkId: 'B13',
|
|
139
144
|
name: 'unmediated-filesystem',
|
|
145
|
+
subject: specifier,
|
|
140
146
|
severity: 'medium',
|
|
141
147
|
title: `Imports \`${specifier}\` rather than using the \`ctx.fs\` service`,
|
|
142
148
|
detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
|
|
@@ -162,6 +168,7 @@ function checkSeamReplacement(file, node, accumulator) {
|
|
|
162
168
|
accumulator.findings.push(tierB({
|
|
163
169
|
checkId: 'B1',
|
|
164
170
|
name: 'seam-replacement',
|
|
171
|
+
subject: `${seam}.${method}`,
|
|
165
172
|
severity: critical ? 'critical' : 'high',
|
|
166
173
|
title: `Replaces the \`${seam}\` capability seam via \`.${method}()\``,
|
|
167
174
|
detail: `\`${seam}\` is a catalogued core service. Providing it from a third-party layer substitutes this `
|
|
@@ -185,6 +192,7 @@ function checkSystemPrompt(file, node, accumulator) {
|
|
|
185
192
|
accumulator.findings.push(tierB({
|
|
186
193
|
checkId: 'B5',
|
|
187
194
|
name: 'system-prompt-mutation',
|
|
195
|
+
subject: isAssembleListener ? 'system-prompt/assemble' : callee.name.text,
|
|
188
196
|
severity: 'high',
|
|
189
197
|
title: isAssembleListener
|
|
190
198
|
? 'Listens on `system-prompt/assemble`'
|
|
@@ -207,6 +215,7 @@ function checkNestedMount(file, node, accumulator) {
|
|
|
207
215
|
accumulator.findings.push(tierB({
|
|
208
216
|
checkId: 'B11',
|
|
209
217
|
name: 'nested-plugin-mount',
|
|
218
|
+
subject: 'runtime-mount',
|
|
210
219
|
severity: 'high',
|
|
211
220
|
title: 'Mounts further plugins at runtime',
|
|
212
221
|
detail: 'A layer that mounts other layers moves the analysis target: what actually runs is decided by code '
|
|
@@ -257,6 +266,7 @@ function checkDynamicCode(file, node, accumulator) {
|
|
|
257
266
|
accumulator.findings.push(tierB({
|
|
258
267
|
checkId: 'B12',
|
|
259
268
|
name: 'dynamic-code-construction',
|
|
269
|
+
subject: 'runtime-code',
|
|
260
270
|
severity: 'high',
|
|
261
271
|
title: 'Builds and runs code at runtime',
|
|
262
272
|
detail: 'Whatever this evaluates is not in the package and cannot be analysed from it. Construction alone is '
|
|
@@ -269,11 +279,14 @@ function checkDynamicCode(file, node, accumulator) {
|
|
|
269
279
|
/** B6 — reading a credential. */
|
|
270
280
|
function checkCredentialRead(file, node, accumulator) {
|
|
271
281
|
let title = null;
|
|
282
|
+
let subject = '';
|
|
272
283
|
if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
273
284
|
const outer = node.expression;
|
|
274
285
|
if (ts.isIdentifier(outer.expression) && outer.expression.text === 'process' && outer.name.text === 'env') {
|
|
275
|
-
if (SECRET_ENV_KEY.test(node.name.text))
|
|
286
|
+
if (SECRET_ENV_KEY.test(node.name.text)) {
|
|
276
287
|
title = `Reads the environment variable \`${node.name.text}\``;
|
|
288
|
+
subject = `env:${node.name.text}`;
|
|
289
|
+
}
|
|
277
290
|
}
|
|
278
291
|
}
|
|
279
292
|
if (ts.isElementAccessExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
@@ -282,19 +295,23 @@ function checkCredentialRead(file, node, accumulator) {
|
|
|
282
295
|
if (ts.isIdentifier(outer.expression) && outer.expression.text === 'process' && outer.name.text === 'env'
|
|
283
296
|
&& key !== null && SECRET_ENV_KEY.test(key)) {
|
|
284
297
|
title = `Reads the environment variable \`${key}\``;
|
|
298
|
+
subject = `env:${key}`;
|
|
285
299
|
}
|
|
286
300
|
}
|
|
287
301
|
if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && CREDENTIAL_PATH.test(node.text)) {
|
|
288
302
|
title = `References the credential location \`${node.text}\``;
|
|
303
|
+
subject = `path:${node.text}`;
|
|
289
304
|
}
|
|
290
305
|
if (ts.isPropertyAccessExpression(node) && node.name.text === 'credentials' && ts.isIdentifier(node.expression)) {
|
|
291
306
|
title = 'Reads the `credentials` service';
|
|
307
|
+
subject = 'service:credentials';
|
|
292
308
|
}
|
|
293
309
|
if (title === null)
|
|
294
310
|
return;
|
|
295
311
|
const finding = tierB({
|
|
296
312
|
checkId: 'B6',
|
|
297
313
|
name: 'credential-read',
|
|
314
|
+
subject,
|
|
298
315
|
severity: 'medium',
|
|
299
316
|
title,
|
|
300
317
|
detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
|
|
@@ -318,6 +335,7 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
318
335
|
accumulator.findings.push(tierB({
|
|
319
336
|
checkId: 'B10',
|
|
320
337
|
name: 'model-visible-injection',
|
|
338
|
+
subject: match.ruleId,
|
|
321
339
|
severity: 'high',
|
|
322
340
|
title: `Tool description ${match.meaning}`,
|
|
323
341
|
detail: `Heuristic \`${match.ruleId}\` matched a tool \`description\`, which is prompt text the model receives `
|
|
@@ -336,6 +354,7 @@ function checkNetworkGlobals(file, node, accumulator) {
|
|
|
336
354
|
const finding = tierB({
|
|
337
355
|
checkId: 'B7',
|
|
338
356
|
name: 'network-egress',
|
|
357
|
+
subject: callee.text,
|
|
339
358
|
severity: 'medium',
|
|
340
359
|
title: `Calls \`${callee.text}()\``,
|
|
341
360
|
detail: 'The harness\'s own dynamic-package sandbox traps `fetch` and redirects it to the `ctx.web` service, so '
|
|
@@ -381,7 +400,40 @@ export function runTierB(input) {
|
|
|
381
400
|
const pair = pairFinding(accumulator);
|
|
382
401
|
if (pair !== null)
|
|
383
402
|
accumulator.findings.push(pair);
|
|
384
|
-
return accumulator
|
|
403
|
+
return escalateProcessImports(accumulator);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* B9 — raise a process-API import from `medium` to `high` when the same package
|
|
407
|
+
* also reads a credential or reaches the network.
|
|
408
|
+
*
|
|
409
|
+
* A bare `import 'node:child_process'` was hardcoded `critical` and fired on
|
|
410
|
+
* half the published ecosystem. A severity that common is not a severity: it
|
|
411
|
+
* pushed a package disabling `fs-sandbox` down a list of a thousand identical
|
|
412
|
+
* criticals. Spawning a process is what a plugin that wraps `git`, `ffmpeg` or
|
|
413
|
+
* a language server does, and the tool cannot tell that from the other thing.
|
|
414
|
+
*
|
|
415
|
+
* The pairing is exactly the one B8 already uses — a credential read *and* a
|
|
416
|
+
* network call in the same package — and for the same reason: the combination
|
|
417
|
+
* is what changes the question from "can it run a program" to "can it run a
|
|
418
|
+
* program with something worth sending, and somewhere to send it". Either half
|
|
419
|
+
* alone is not enough, and it would not narrow anything if it were: 68 % of
|
|
420
|
+
* published plugins reach the network at all.
|
|
421
|
+
*
|
|
422
|
+
* That pairing is still a capability, not a dataflow, so it stops at `high`.
|
|
423
|
+
* @param accumulator - the accumulated Tier B state.
|
|
424
|
+
* @returns the findings, with B9 severities settled.
|
|
425
|
+
*/
|
|
426
|
+
function escalateProcessImports(accumulator) {
|
|
427
|
+
const paired = accumulator.credentialRead !== null && accumulator.networkCall !== null;
|
|
428
|
+
if (!paired)
|
|
429
|
+
return accumulator.findings;
|
|
430
|
+
return accumulator.findings.map(finding => finding.checkId !== 'B9' ? finding : {
|
|
431
|
+
...finding,
|
|
432
|
+
severity: 'high',
|
|
433
|
+
detail: `${finding.detail} This package also reads a credential or reaches the network, which is why this is `
|
|
434
|
+
+ 'graded above a bare process import: the two capabilities together are what an exfiltration needs. It '
|
|
435
|
+
+ 'remains a capability report — nothing here shows the two are connected.',
|
|
436
|
+
});
|
|
385
437
|
}
|
|
386
438
|
/**
|
|
387
439
|
* B8 — a credential read and a network call in the same package.
|
|
@@ -393,10 +445,15 @@ function pairFinding(accumulator) {
|
|
|
393
445
|
const network = accumulator.networkCall;
|
|
394
446
|
if (credential === null || network === null)
|
|
395
447
|
return null;
|
|
396
|
-
|
|
448
|
+
// Not critical. This pair fires on 18 % of the published ecosystem — every
|
|
449
|
+
// telemetry client and every authenticated API client trips it — and the
|
|
450
|
+
// finding's own text says it is not a verdict. A severity that says "do not
|
|
451
|
+
// treat this as a verdict" cannot be the top one.
|
|
452
|
+
const severity = 'high';
|
|
397
453
|
return tierB({
|
|
398
454
|
checkId: 'B8',
|
|
399
455
|
name: 'exfiltration-capability',
|
|
456
|
+
subject: 'credential-and-egress',
|
|
400
457
|
severity,
|
|
401
458
|
title: 'This package can read a credential and can make a network call',
|
|
402
459
|
detail: 'This is a capability, not a dataflow. The tool found a credential read at '
|
package/lib/checks/tier-c.js
CHANGED
|
@@ -33,7 +33,7 @@ const NAMED_TARGET_CALLEES = new Set([
|
|
|
33
33
|
* @returns the complete finding.
|
|
34
34
|
*/
|
|
35
35
|
function tierC(finding) {
|
|
36
|
-
return { ...finding, tier: 'C', confidence: 'moderate' };
|
|
36
|
+
return { ...finding, tier: 'C', confidence: 'moderate', examples: [finding.evidence], occurrences: 1 };
|
|
37
37
|
}
|
|
38
38
|
/** C1 — source that is not written to be read. */
|
|
39
39
|
function checkMinification(input) {
|
|
@@ -57,12 +57,13 @@ function checkMinification(input) {
|
|
|
57
57
|
findings.push(tierC({
|
|
58
58
|
checkId: 'C1',
|
|
59
59
|
name: 'minified-source',
|
|
60
|
+
subject: 'minified-source',
|
|
60
61
|
severity: 'medium',
|
|
61
|
-
title:
|
|
62
|
-
detail: `
|
|
63
|
-
+
|
|
64
|
-
+ 'syntax, and it reads minified syntax no better than a person does. Every Tier B
|
|
65
|
-
+ 'package is unreliable while
|
|
62
|
+
title: 'Ships source that is minified or generated',
|
|
63
|
+
detail: `In \`${path}\` the longest line is ${longest} characters across ${lines.length} line(s), and lines `
|
|
64
|
+
+ `that long are ${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability `
|
|
65
|
+
+ 'detection reads syntax, and it reads minified syntax no better than a person does. Every Tier B '
|
|
66
|
+
+ 'negative for this package is unreliable while a file like this is in it.',
|
|
66
67
|
evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
|
|
67
68
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
68
69
|
}));
|
|
@@ -81,8 +82,9 @@ function checkDynamicDispatch(input) {
|
|
|
81
82
|
findings.push(tierC({
|
|
82
83
|
checkId: 'C2',
|
|
83
84
|
name: 'dynamic-dispatch',
|
|
85
|
+
subject: what,
|
|
84
86
|
severity: 'high',
|
|
85
|
-
title:
|
|
87
|
+
title: `Shipped source ${what}`,
|
|
86
88
|
detail: 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of them, so no '
|
|
87
89
|
+ 'Tier B negative for this package carries any information. A Tier B positive still does — the tool saw '
|
|
88
90
|
+ 'what it saw.',
|
|
@@ -209,6 +211,7 @@ function checkSourcelessBuild(input) {
|
|
|
209
211
|
findings.push(tierC({
|
|
210
212
|
checkId: 'C3',
|
|
211
213
|
name: 'sourceless-build-output',
|
|
214
|
+
subject: 'no-authored-source',
|
|
212
215
|
severity: 'low',
|
|
213
216
|
title: `Ships ${built.length} built file(s) and no source`,
|
|
214
217
|
detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
|
|
@@ -222,8 +225,9 @@ function checkSourcelessBuild(input) {
|
|
|
222
225
|
findings.push(tierC({
|
|
223
226
|
checkId: 'C6',
|
|
224
227
|
name: 'minified-artifact',
|
|
228
|
+
subject: 'min-js',
|
|
225
229
|
severity: 'low',
|
|
226
|
-
title:
|
|
230
|
+
title: 'Ships a minified artifact',
|
|
227
231
|
detail: 'A `.min.js` file is output, not source. It was still parsed, but nothing about its readability '
|
|
228
232
|
+ 'supports a confident negative.',
|
|
229
233
|
evidence: { file: path },
|
|
@@ -244,6 +248,7 @@ function checkUnreadableFiles(input) {
|
|
|
244
248
|
return [...byReason].map(([reason, paths]) => tierC({
|
|
245
249
|
checkId: 'C4',
|
|
246
250
|
name: 'unreadable-payload',
|
|
251
|
+
subject: reason,
|
|
247
252
|
severity: reason === 'binary' ? 'medium' : 'low',
|
|
248
253
|
title: `${paths.length} file(s) were not analysed (${reason})`,
|
|
249
254
|
detail: reason === 'binary'
|
|
@@ -259,6 +264,7 @@ function checkPatchWalkLimit(input) {
|
|
|
259
264
|
return input.patches.filter(patch => patch.limit !== null).map(patch => tierC({
|
|
260
265
|
checkId: 'C5',
|
|
261
266
|
name: 'patch-walk-truncated',
|
|
267
|
+
subject: patch.file,
|
|
262
268
|
severity: 'high',
|
|
263
269
|
title: `\`${patch.file}\` was only read in part (${patch.limit === 'depth' ? 'nesting' : 'node count'} ceiling)`,
|
|
264
270
|
detail: patch.limit === 'depth'
|
package/lib/cli.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import process from 'node:process';
|
|
12
12
|
import { exceedsThreshold, inspect, TOOL_VERSION } from "./inspect.js";
|
|
13
13
|
import { SEVERITY_RANK } from "./model.js";
|
|
14
|
+
import { inspectFromNpm } from "./npm.js";
|
|
15
|
+
import { DEFAULT_REGISTRY } from "./registry.js";
|
|
14
16
|
import { renderHuman, renderJson } from "./report.js";
|
|
15
17
|
/** Exit codes this tool uses. */
|
|
16
18
|
export const EXIT = {
|
|
@@ -22,11 +24,18 @@ const USAGE = `dsh-inspect — know what a DeepSeek Harness plugin does before y
|
|
|
22
24
|
|
|
23
25
|
Usage
|
|
24
26
|
dsh-inspect <target> [options]
|
|
27
|
+
dsh-inspect --from-npm <name>[@<version>] [options]
|
|
25
28
|
|
|
26
29
|
<target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
|
|
27
30
|
Nothing in the target is installed, built, or executed.
|
|
28
31
|
|
|
29
32
|
Options
|
|
33
|
+
--from-npm <spec> Fetch a published package from the registry, verify its
|
|
34
|
+
dist.integrity hash, and analyse it in memory. This is
|
|
35
|
+
the only mode that opens a socket, and it never runs
|
|
36
|
+
npm, writes to disk, or executes an install script.
|
|
37
|
+
--registry <url> Registry base URL for --from-npm.
|
|
38
|
+
(default: ${DEFAULT_REGISTRY})
|
|
30
39
|
--json Emit the machine-readable JSON document on stdout.
|
|
31
40
|
--fail-on <severity> Exit 1 at or above this severity.
|
|
32
41
|
critical | high | medium | low | none (default: high)
|
|
@@ -40,7 +49,7 @@ Exit codes
|
|
|
40
49
|
2 analysis could not be performed
|
|
41
50
|
|
|
42
51
|
To inspect a published package without installing it:
|
|
43
|
-
npm
|
|
52
|
+
dsh-inspect --from-npm <name>@<version>
|
|
44
53
|
`;
|
|
45
54
|
/** Raised for a malformed command line; the message is printed and the tool exits 2. */
|
|
46
55
|
export class UsageError extends Error {
|
|
@@ -53,9 +62,23 @@ export class UsageError extends Error {
|
|
|
53
62
|
*/
|
|
54
63
|
export function parseArgs(argv) {
|
|
55
64
|
let target = null;
|
|
65
|
+
let fromNpm = null;
|
|
66
|
+
let registry = DEFAULT_REGISTRY;
|
|
56
67
|
let json = false;
|
|
57
68
|
let failOn = 'high';
|
|
58
69
|
let color = process.stdout.isTTY === true;
|
|
70
|
+
/**
|
|
71
|
+
* Read the value of an option that takes one.
|
|
72
|
+
* @param index - the option's own position in argv.
|
|
73
|
+
* @param option - the option name, for the error message.
|
|
74
|
+
* @returns the value.
|
|
75
|
+
*/
|
|
76
|
+
const value = (index, option) => {
|
|
77
|
+
const next = argv[index + 1];
|
|
78
|
+
if (next === undefined)
|
|
79
|
+
throw new UsageError(`${option} needs a value`);
|
|
80
|
+
return next;
|
|
81
|
+
};
|
|
59
82
|
for (let index = 0; index < argv.length; index += 1) {
|
|
60
83
|
const argument = argv[index] ?? '';
|
|
61
84
|
if (argument === '--help' || argument === '-h') {
|
|
@@ -78,15 +101,25 @@ export function parseArgs(argv) {
|
|
|
78
101
|
color = true;
|
|
79
102
|
continue;
|
|
80
103
|
}
|
|
104
|
+
if (argument === '--from-npm') {
|
|
105
|
+
if (fromNpm !== null)
|
|
106
|
+
throw new UsageError('only one package may be fetched at a time');
|
|
107
|
+
fromNpm = value(index, '--from-npm');
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (argument === '--registry') {
|
|
112
|
+
registry = value(index, '--registry');
|
|
113
|
+
index += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
81
116
|
if (argument === '--fail-on') {
|
|
82
|
-
const
|
|
117
|
+
const severity = value(index, '--fail-on');
|
|
83
118
|
index += 1;
|
|
84
|
-
if (
|
|
85
|
-
throw new UsageError(
|
|
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}`);
|
|
119
|
+
if (severity !== 'none' && !(severity in SEVERITY_RANK)) {
|
|
120
|
+
throw new UsageError(`--fail-on must be one of critical, high, medium, low, none — got ${severity}`);
|
|
88
121
|
}
|
|
89
|
-
failOn =
|
|
122
|
+
failOn = severity;
|
|
90
123
|
continue;
|
|
91
124
|
}
|
|
92
125
|
if (argument.startsWith('-'))
|
|
@@ -95,9 +128,18 @@ export function parseArgs(argv) {
|
|
|
95
128
|
throw new UsageError('only one target may be inspected at a time');
|
|
96
129
|
target = argument;
|
|
97
130
|
}
|
|
131
|
+
// Fetching is opt-in per invocation and never a fallback: a mistyped path
|
|
132
|
+
// must not become a registry lookup, and a registry spec must not silently
|
|
133
|
+
// shadow a local directory of the same name.
|
|
134
|
+
if (target !== null && fromNpm !== null) {
|
|
135
|
+
throw new UsageError('--from-npm fetches a published package; it cannot be combined with a local target');
|
|
136
|
+
}
|
|
137
|
+
const common = { registry, json, failOn, color };
|
|
138
|
+
if (fromNpm !== null)
|
|
139
|
+
return { ...common, target: null, fromNpm };
|
|
98
140
|
if (target === null)
|
|
99
|
-
throw new UsageError('a target directory or tarball is required');
|
|
100
|
-
return {
|
|
141
|
+
throw new UsageError('a target directory or tarball is required, or --from-npm <name>');
|
|
142
|
+
return { ...common, target, fromNpm: null };
|
|
101
143
|
}
|
|
102
144
|
/**
|
|
103
145
|
* Run one invocation.
|
|
@@ -116,7 +158,9 @@ export async function main(argv) {
|
|
|
116
158
|
if (options === null)
|
|
117
159
|
return EXIT.clean;
|
|
118
160
|
try {
|
|
119
|
-
const report =
|
|
161
|
+
const report = options.fromNpm === null
|
|
162
|
+
? await inspect(options.target)
|
|
163
|
+
: await inspectFromNpm(options.fromNpm, { registry: options.registry });
|
|
120
164
|
process.stdout.write(options.json ? renderJson(report) : renderHuman(report, options.color));
|
|
121
165
|
return exceedsThreshold(report, options.failOn) ? EXIT.findings : EXIT.clean;
|
|
122
166
|
}
|
package/lib/files.js
CHANGED
|
@@ -23,7 +23,7 @@ export function isSourceFile(path) {
|
|
|
23
23
|
/**
|
|
24
24
|
* Whether a path is markdown that can reach the model verbatim.
|
|
25
25
|
*
|
|
26
|
-
* The reach is conditional
|
|
26
|
+
* The reach is conditional: a `SKILL.md` inside an npm
|
|
27
27
|
* package is only discovered when the plugin registers it through
|
|
28
28
|
* `ctx.skills`, when a patch row redirects a skill root into the package, or
|
|
29
29
|
* when something copies it into the user's workspace. This predicate answers
|
package/lib/index.js
CHANGED
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* The ceiling is triage, not containment. See `README.md` §Limitations.
|
|
12
12
|
* @module dsh-plugin-inspector
|
|
13
13
|
*/
|
|
14
|
-
export { exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
|
|
14
|
+
export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
|
|
15
|
+
export { inspectFromNpm, precheck } from "./npm.js";
|
|
16
|
+
export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
|
|
15
17
|
export { renderHuman, renderJson } from "./report.js";
|
|
16
18
|
export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, } from "./cordis-yaml.js";
|
|
17
19
|
export { declaredPackages, ManifestError, parseManifest } from "./manifest.js";
|
|
18
|
-
export { DEFAULT_LIMITS, loadSource, SourceError } from "./source.js";
|
|
20
|
+
export { DEFAULT_LIMITS, loadSource, loadTarballBuffer, SourceError, } from "./source.js";
|
|
19
21
|
export { globMatch, publishSet } from "./publish.js";
|
|
20
22
|
export { INJECTION_RULES, scanInjection } from "./injection.js";
|
|
21
|
-
export { compareFindings, SEVERITIES, SEVERITY_RANK, summarize, } from "./model.js";
|
|
23
|
+
export { aggregateFindings, compareFindings, MAX_EXAMPLES, SEVERITIES, SEVERITY_RANK, summarize, } from "./model.js";
|
|
22
24
|
export { CORE_ROWS, CORE_ROW_IDS, HARNESS_BUNDLE_PACKAGES, HARNESS_REFERENCE, SEAM_KEYS, SECURITY_ROW_IDS, WATERFALL_EVENTS, } from "./knowledge.js";
|
package/lib/inspect.js
CHANGED
|
@@ -13,7 +13,7 @@ import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cord
|
|
|
13
13
|
import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
|
|
14
14
|
import { HARNESS_REFERENCE } from "./knowledge.js";
|
|
15
15
|
import { parseManifest } from "./manifest.js";
|
|
16
|
-
import { SEVERITY_RANK, compareFindings, summarize, } from "./model.js";
|
|
16
|
+
import { SEVERITY_RANK, aggregateFindings, compareFindings, summarize, } from "./model.js";
|
|
17
17
|
import { loadSource } from "./source.js";
|
|
18
18
|
import { runTierA } from "./checks/tier-a.js";
|
|
19
19
|
import { runTierB } from "./checks/tier-b.js";
|
|
@@ -69,13 +69,26 @@ function downgrade(confidence, degraded) {
|
|
|
69
69
|
return 'moderate';
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Inspect a plugin package.
|
|
72
|
+
* Inspect a plugin package on disk.
|
|
73
|
+
*
|
|
74
|
+
* Nothing on this path opens a socket: neither this module nor anything it
|
|
75
|
+
* imports can reach the registry, which is what makes "a directory or tarball
|
|
76
|
+
* scan never fetches" structural rather than a promise.
|
|
73
77
|
* @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
|
|
74
78
|
* @returns the complete report.
|
|
75
79
|
* @throws SourceError or ManifestError when the target cannot be analysed at all.
|
|
76
80
|
*/
|
|
77
81
|
export async function inspect(target) {
|
|
78
|
-
|
|
82
|
+
return analyze(await loadSource(target));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Run every check over an already-decoded package.
|
|
86
|
+
* @param source - the decoded package.
|
|
87
|
+
* @param registry - provenance, when the bytes were fetched from a registry.
|
|
88
|
+
* @returns the complete report.
|
|
89
|
+
* @throws ManifestError when the manifest cannot be read.
|
|
90
|
+
*/
|
|
91
|
+
export function analyze(source, registry) {
|
|
79
92
|
const manifest = parseManifest(source.files.get('package.json') ?? '');
|
|
80
93
|
const declared = manifest.dsh.bundle?.patch;
|
|
81
94
|
const mountsAsBundle = declared !== undefined;
|
|
@@ -114,7 +127,10 @@ export async function inspect(target) {
|
|
|
114
127
|
...runTierB(input).map(finding => ({ ...finding, confidence: downgrade(finding.confidence, degraded) })),
|
|
115
128
|
...tierC,
|
|
116
129
|
];
|
|
117
|
-
|
|
130
|
+
// Aggregate before sorting: the report is a list of decisions, one per check
|
|
131
|
+
// per subject, and the count travels inside the finding. A package importing
|
|
132
|
+
// `node:fs` from eleven files states that once.
|
|
133
|
+
const findings = aggregateFindings(raw).sort(compareFindings);
|
|
118
134
|
const facts = {
|
|
119
135
|
packageName: manifest.name,
|
|
120
136
|
packageVersion: manifest.version,
|
|
@@ -141,9 +157,9 @@ export async function inspect(target) {
|
|
|
141
157
|
unpublishedFiles: source.unpublishedFiles,
|
|
142
158
|
};
|
|
143
159
|
return {
|
|
144
|
-
schemaVersion:
|
|
160
|
+
schemaVersion: 2,
|
|
145
161
|
tool: { name: TOOL_NAME, version: TOOL_VERSION, harnessReference: HARNESS_REFERENCE },
|
|
146
|
-
target: { kind: source.kind, path: source.path },
|
|
162
|
+
target: { kind: source.kind, path: source.path, ...registry === undefined ? {} : { registry } },
|
|
147
163
|
facts,
|
|
148
164
|
analysis: {
|
|
149
165
|
integrity: degraded ? 'degraded' : 'complete',
|
package/lib/model.js
CHANGED
|
Binary file
|
package/lib/npm.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--from-npm` — inspecting a published package without installing it.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only path in the tool that reaches a network, and it is
|
|
5
|
+
* separate from `inspect.ts` on purpose: a directory or tarball scan cannot
|
|
6
|
+
* arrive here, because nothing on that path imports this file. The steps are
|
|
7
|
+
* fixed and their order is the guarantee:
|
|
8
|
+
*
|
|
9
|
+
* 1. read the version document (~3 KB) — which already answers
|
|
10
|
+
* `hasInstallScript`, the install lifecycle scripts, and `dsh.bundle`;
|
|
11
|
+
* 2. download the tarball into memory;
|
|
12
|
+
* 3. verify `dist.integrity` **before** anything parses a byte of it;
|
|
13
|
+
* 4. decode in memory and analyse, exactly as the tarball path does.
|
|
14
|
+
*
|
|
15
|
+
* No subprocess, no disk write, no lifecycle script, and no `npm pack`.
|
|
16
|
+
* @module dsh-plugin-inspector/npm
|
|
17
|
+
*/
|
|
18
|
+
import { analyze } from "./inspect.js";
|
|
19
|
+
import { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, resolvePackage, } from "./registry.js";
|
|
20
|
+
import { loadTarballBuffer } from "./source.js";
|
|
21
|
+
/**
|
|
22
|
+
* The metadata pre-check, which needs no tarball.
|
|
23
|
+
*
|
|
24
|
+
* A caller sweeping many packages can read this for each of them at a few
|
|
25
|
+
* kilobytes apiece and decide which ones are worth downloading.
|
|
26
|
+
* @param spec - `<name>` or `<name>@<version>`.
|
|
27
|
+
* @param options - where to fetch from.
|
|
28
|
+
* @returns what the version document says.
|
|
29
|
+
* @throws RegistryError when the package or version does not resolve.
|
|
30
|
+
*/
|
|
31
|
+
export async function precheck(spec, options = {}) {
|
|
32
|
+
return resolvePackage(parseSpec(spec), options);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a published package and inspect it in memory.
|
|
36
|
+
* @param spec - `<name>` or `<name>@<version>`; no version means the `latest` tag.
|
|
37
|
+
* @param options - where to fetch from.
|
|
38
|
+
* @returns the complete report, carrying the registry provenance.
|
|
39
|
+
* @throws RegistryError when the package cannot be resolved, fetched, or verified.
|
|
40
|
+
* @throws SourceError or ManifestError when the fetched tarball is not a package.
|
|
41
|
+
*/
|
|
42
|
+
export async function inspectFromNpm(spec, options = {}) {
|
|
43
|
+
const resolved = await precheck(spec, options);
|
|
44
|
+
const verified = await fetchVerifiedTarball(resolved, options);
|
|
45
|
+
const provenance = {
|
|
46
|
+
spec,
|
|
47
|
+
registry: (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, ''),
|
|
48
|
+
resolvedVersion: resolved.version,
|
|
49
|
+
tarball: resolved.tarball,
|
|
50
|
+
digest: verified.digest,
|
|
51
|
+
algorithm: verified.algorithm,
|
|
52
|
+
hasInstallScript: resolved.hasInstallScript,
|
|
53
|
+
metadataBytes: resolved.metadataBytes,
|
|
54
|
+
tarballBytes: verified.bytes.byteLength,
|
|
55
|
+
};
|
|
56
|
+
const source = await loadTarballBuffer(verified.bytes, `npm:${resolved.name}@${resolved.version}`);
|
|
57
|
+
return analyze(source, provenance);
|
|
58
|
+
}
|