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.
@@ -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
- severity: 'critical',
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.findings;
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
- const severity = 'critical';
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 '
@@ -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: `\`${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.',
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: `\`${path}\` ${what}`,
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: `\`${path}\` is a minified artifact`,
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 pack <name>@<version> --pack-destination /tmp && dsh-inspect /tmp/<name>-<version>.tgz
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 value = argv[index + 1];
117
+ const severity = value(index, '--fail-on');
83
118
  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}`);
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 = value;
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 { target, json, failOn, color };
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 = await inspect(options.target);
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 and PLAN.md §6.1 says so: a `SKILL.md` inside an npm
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
- const source = await loadSource(target);
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
- const findings = [...raw].sort(compareFindings);
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: 1,
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
+ }