dsh-plugin-inspector 0.1.0 → 0.2.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.
@@ -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/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
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Fetching a published package from an npm registry, and proving the bytes are
3
+ * the ones the registry vouched for.
4
+ *
5
+ * This is the one module in the tool that opens a socket, and it exists behind
6
+ * one explicit flag. A network fetch is not execution — nothing here installs,
7
+ * unpacks to disk, or runs a lifecycle script — but it is a side effect the
8
+ * tool's other two modes do not have, so it is never reached implicitly: a
9
+ * directory or tarball scan cannot get here, because neither imports this
10
+ * module.
11
+ *
12
+ * The order of operations is the security property. The packument is read
13
+ * first, which is ~3 KB and already answers `hasInstallScript`, the install
14
+ * lifecycle scripts, and whether the package declares `dsh.bundle` — a
15
+ * pre-check that needs no tarball at all. Only then is the tarball fetched, and
16
+ * its `dist.integrity` hash is verified **before** any byte of it reaches the
17
+ * tar parser. A hash mismatch is a refusal, not a warning: the whole point of
18
+ * the mode is that the analysed bytes are the published bytes.
19
+ * @module dsh-plugin-inspector/registry
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { INSTALL_LIFECYCLE_SCRIPTS } from "./knowledge.js";
23
+ import { MAX_TOTAL_BYTES } from "./source.js";
24
+ /** The public npm registry, used when no other is named. */
25
+ export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
26
+ /** Largest packument the tool will read. A version document is a few kilobytes. */
27
+ export const MAX_METADATA_BYTES = 4 * 1024 * 1024;
28
+ /**
29
+ * Largest compressed tarball the tool will download. The decompressed stream is
30
+ * capped separately, and lower, by the tar reader.
31
+ */
32
+ export const MAX_TARBALL_BYTES = MAX_TOTAL_BYTES;
33
+ /** Hash algorithms accepted in a Subresource Integrity string, weakest last. */
34
+ const SRI_ALGORITHMS = new Set(['sha512', 'sha384', 'sha256']);
35
+ /**
36
+ * npm package names, as the registry accepts them. Validated because the name
37
+ * is interpolated into a URL: `../` in a package name is a request for a
38
+ * different endpoint, and a `http://…` "name" is a request to a different host.
39
+ */
40
+ const PACKAGE_NAME = /^(?:@[a-z0-9~][a-z0-9-._~]*\/)?[a-z0-9~][a-z0-9-._~]*$/;
41
+ /** Versions and dist-tags, as they may appear after the `@` in a spec. */
42
+ const VERSION_OR_TAG = /^[A-Za-z0-9][A-Za-z0-9-._+]*$/;
43
+ /** Thrown when a package cannot be resolved, fetched, or verified. */
44
+ export class RegistryError extends Error {
45
+ }
46
+ /**
47
+ * Split a `<name>` or `<name>@<version>` argument.
48
+ *
49
+ * The `@` that starts a scope is not a separator, so `@scope/name` has no
50
+ * version and `@scope/name@1.2.3` has one.
51
+ * @param spec - the argument as typed.
52
+ * @returns the package name and the requested version, if any.
53
+ * @throws RegistryError when the name or version is not one the registry accepts.
54
+ */
55
+ export function parseSpec(spec) {
56
+ const separator = spec.lastIndexOf('@');
57
+ const split = separator > 0;
58
+ const name = split ? spec.slice(0, separator) : spec;
59
+ const version = split ? spec.slice(separator + 1) : null;
60
+ if (!PACKAGE_NAME.test(name))
61
+ throw new RegistryError(`not an npm package name: ${spec}`);
62
+ if (version !== null && !VERSION_OR_TAG.test(version)) {
63
+ throw new RegistryError(`not a version or dist-tag: ${version}`);
64
+ }
65
+ return { name, version };
66
+ }
67
+ /**
68
+ * Read one response body under a ceiling, without materialising more than the
69
+ * ceiling allows. A registry that streams forever is a hang, and a hang in a
70
+ * gate is a denial of service.
71
+ * @param response - the fetch response.
72
+ * @param limit - the ceiling in bytes.
73
+ * @param what - what is being read, for the error message.
74
+ * @returns the body.
75
+ * @throws RegistryError when the body passes the ceiling.
76
+ */
77
+ async function readCapped(response, limit, what) {
78
+ const body = response.body;
79
+ if (body === null)
80
+ return Buffer.alloc(0);
81
+ const chunks = [];
82
+ let total = 0;
83
+ for await (const chunk of body) {
84
+ total += chunk.byteLength;
85
+ if (total > limit)
86
+ throw new RegistryError(`${what} is larger than ${limit} bytes`);
87
+ chunks.push(Buffer.from(chunk));
88
+ }
89
+ return Buffer.concat(chunks);
90
+ }
91
+ /**
92
+ * GET a URL, failing loud on anything but a 2xx.
93
+ * @param url - the absolute URL.
94
+ * @param accept - the Accept header.
95
+ * @param options - registry options carrying the fetch implementation.
96
+ * @returns the response.
97
+ * @throws RegistryError on a transport failure or a non-2xx status.
98
+ */
99
+ async function get(url, accept, options) {
100
+ const call = options.fetch ?? globalThis.fetch;
101
+ let response;
102
+ try {
103
+ response = await call(url, { headers: { accept, 'user-agent': 'dsh-plugin-inspector' } });
104
+ }
105
+ catch (error) {
106
+ throw new RegistryError(`cannot reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
107
+ }
108
+ if (!response.ok)
109
+ throw new RegistryError(`${url} returned HTTP ${response.status}`);
110
+ return response;
111
+ }
112
+ /**
113
+ * Read the packument for one version, which is the pre-check.
114
+ * @param spec - the package name and requested version.
115
+ * @param options - where to fetch from.
116
+ * @returns everything the metadata says, including the tarball URL and its hash.
117
+ * @throws RegistryError when the package or version does not resolve, or when
118
+ * the document does not carry a tarball URL on the registry's own origin.
119
+ */
120
+ export async function resolvePackage(spec, options = {}) {
121
+ const registry = (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, '');
122
+ const url = `${registry}/${spec.name}/${encodeURIComponent(spec.version ?? 'latest')}`;
123
+ const response = await get(url, 'application/json', options);
124
+ const body = await readCapped(response, MAX_METADATA_BYTES, 'package metadata');
125
+ let document;
126
+ try {
127
+ document = JSON.parse(body.toString('utf8'));
128
+ }
129
+ catch (error) {
130
+ throw new RegistryError(`${url} did not return JSON: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ const record = asRecord(document);
133
+ const dist = asRecord(record.dist);
134
+ const tarball = typeof dist.tarball === 'string' ? dist.tarball : null;
135
+ if (tarball === null)
136
+ throw new RegistryError(`${url} carries no dist.tarball`);
137
+ assertSameOrigin(tarball, registry);
138
+ const scripts = asRecord(record.scripts);
139
+ const dsh = asRecord(record.dsh);
140
+ const bundle = asRecord(dsh.bundle);
141
+ return {
142
+ name: typeof record.name === 'string' ? record.name : spec.name,
143
+ version: typeof record.version === 'string' ? record.version : (spec.version ?? 'latest'),
144
+ tarball,
145
+ integrity: typeof dist.integrity === 'string' ? dist.integrity : null,
146
+ shasum: typeof dist.shasum === 'string' ? dist.shasum : null,
147
+ hasInstallScript: record.hasInstallScript === true,
148
+ lifecycleScripts: INSTALL_LIFECYCLE_SCRIPTS.filter(name => typeof scripts[name] === 'string'),
149
+ bundlePatch: typeof bundle.patch === 'string' ? bundle.patch : null,
150
+ metadataBytes: body.byteLength,
151
+ };
152
+ }
153
+ /**
154
+ * Narrow an unknown JSON value to a record, so a hostile document's `dist: 7`
155
+ * reads as "no fields" rather than throwing somewhere further down.
156
+ * @param value - the parsed JSON value.
157
+ * @returns the value as a record, or an empty one.
158
+ */
159
+ function asRecord(value) {
160
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
161
+ ? value
162
+ : {};
163
+ }
164
+ /**
165
+ * Refuse a tarball URL that points somewhere other than the registry that
166
+ * described it.
167
+ *
168
+ * The integrity hash comes from the same document as the URL, so a hostile
169
+ * registry can always make the two agree — this does not defend against that.
170
+ * What it does refuse is a single doctored packument on an honest registry
171
+ * pointing the download at a host of the attacker's choosing, which would make
172
+ * the tool fetch an arbitrary URL on the user's behalf.
173
+ * @param tarball - the declared tarball URL.
174
+ * @param registry - the registry base URL.
175
+ * @throws RegistryError when the origins differ or the URL is not http(s).
176
+ */
177
+ function assertSameOrigin(tarball, registry) {
178
+ let url;
179
+ let base;
180
+ try {
181
+ url = new URL(tarball);
182
+ base = new URL(registry);
183
+ }
184
+ catch {
185
+ throw new RegistryError(`dist.tarball is not a URL: ${tarball}`);
186
+ }
187
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
188
+ throw new RegistryError(`dist.tarball is not an http(s) URL: ${tarball}`);
189
+ }
190
+ if (url.origin !== base.origin) {
191
+ throw new RegistryError(`dist.tarball ${tarball} is not on the registry's origin ${base.origin}`);
192
+ }
193
+ }
194
+ /**
195
+ * Check downloaded bytes against what the registry published.
196
+ *
197
+ * `dist.integrity` is preferred and is a real check. `dist.shasum` is SHA-1 and
198
+ * is accepted only when there is no `integrity` field at all, which happens on
199
+ * packages published before npm 5; it is recorded in the report as the weaker
200
+ * check it is. No digest at all is a refusal, because "verified" would then be
201
+ * a claim the tool cannot make.
202
+ * @param bytes - the downloaded tarball.
203
+ * @param resolved - what the packument said about it.
204
+ * @returns the algorithm and digest that matched.
205
+ * @throws RegistryError on a mismatch, an unusable digest, or no digest at all.
206
+ */
207
+ export function verifyIntegrity(bytes, resolved) {
208
+ if (resolved.integrity !== null) {
209
+ // An `integrity` field may carry several space-separated digests. One
210
+ // matching digest in a recognised algorithm is the check; an unrecognised
211
+ // algorithm is not silently ignored, it just is not a match.
212
+ for (const entry of resolved.integrity.trim().split(/\s+/)) {
213
+ const dash = entry.indexOf('-');
214
+ const algorithm = dash < 0 ? '' : entry.slice(0, dash);
215
+ if (!SRI_ALGORITHMS.has(algorithm))
216
+ continue;
217
+ const expected = entry.slice(dash + 1);
218
+ const actual = digest(algorithm, bytes, 'base64');
219
+ if (actual !== expected) {
220
+ throw new RegistryError(`integrity check failed for ${resolved.name}@${resolved.version}: `
221
+ + `registry published ${algorithm}-${expected}, downloaded bytes are ${algorithm}-${actual}`);
222
+ }
223
+ return { bytes, algorithm, digest: entry };
224
+ }
225
+ throw new RegistryError(`no usable digest in dist.integrity for ${resolved.name}@${resolved.version}: ${resolved.integrity}`);
226
+ }
227
+ if (resolved.shasum !== null) {
228
+ const actual = digest('sha1', bytes, 'hex');
229
+ if (actual !== resolved.shasum) {
230
+ throw new RegistryError(`shasum check failed for ${resolved.name}@${resolved.version}: `
231
+ + `registry published sha1-${resolved.shasum}, downloaded bytes are sha1-${actual}`);
232
+ }
233
+ return { bytes, algorithm: 'sha1', digest: `sha1-${actual}` };
234
+ }
235
+ throw new RegistryError(`${resolved.name}@${resolved.version} carries neither dist.integrity nor dist.shasum, so the download cannot be verified`);
236
+ }
237
+ /**
238
+ * Hash a buffer.
239
+ * @param algorithm - the hash algorithm.
240
+ * @param bytes - the input.
241
+ * @param encoding - how to render the digest.
242
+ * @returns the digest.
243
+ */
244
+ function digest(algorithm, bytes, encoding) {
245
+ return createHash(algorithm).update(bytes).digest(encoding);
246
+ }
247
+ /**
248
+ * Download a resolved package's tarball and verify it before returning it.
249
+ *
250
+ * Nothing parses the bytes on the way in — they are counted against a ceiling
251
+ * and hashed, and a failed hash throws before any caller can see them.
252
+ * @param resolved - the packument reading for the version to fetch.
253
+ * @param options - where to fetch from.
254
+ * @returns the verified tarball, in memory.
255
+ * @throws RegistryError on a transport failure, an oversized body, or a hash mismatch.
256
+ */
257
+ export async function fetchVerifiedTarball(resolved, options = {}) {
258
+ const response = await get(resolved.tarball, 'application/octet-stream', options);
259
+ const bytes = await readCapped(response, MAX_TARBALL_BYTES, `tarball ${resolved.tarball}`);
260
+ return verifyIntegrity(bytes, resolved);
261
+ }
package/lib/report.js CHANGED
@@ -43,14 +43,16 @@ export function renderJson(report) {
43
43
  * @returns the rendered lines.
44
44
  */
45
45
  function renderFinding(finding, paint) {
46
- const where = finding.evidence.path === undefined
47
- ? finding.evidence.file
48
- : `${finding.evidence.file}:${finding.evidence.path}`;
46
+ const count = finding.occurrences > 1 ? ` ${paint('dim', `(×${finding.occurrences})`)}` : '';
49
47
  const lines = [
50
- `${paint(finding.severity, LABEL[finding.severity])} ${paint('bold', finding.title)}`,
48
+ `${paint(finding.severity, LABEL[finding.severity])} ${paint('bold', finding.title)}${count}`,
51
49
  ` ${paint('dim', `${finding.checkId} ${finding.name} · tier ${finding.tier} · confidence ${finding.confidence}`)}`,
52
- ` ${paint('dim', where)}`,
53
50
  ];
51
+ for (const example of finding.examples)
52
+ lines.push(` ${paint('dim', locate(example))}`);
53
+ if (finding.occurrences > finding.examples.length) {
54
+ lines.push(` ${paint('dim', `… and ${finding.occurrences - finding.examples.length} more site(s)`)}`);
55
+ }
54
56
  if (finding.evidence.snippet !== undefined)
55
57
  lines.push(` ${paint('dim', `> ${finding.evidence.snippet}`)}`);
56
58
  for (const line of wrap(finding.detail, 88))
@@ -60,6 +62,14 @@ function renderFinding(finding, paint) {
60
62
  lines.push('');
61
63
  return lines;
62
64
  }
65
+ /**
66
+ * Render one example site as `file:locator`.
67
+ * @param evidence - the site.
68
+ * @returns the location.
69
+ */
70
+ function locate(evidence) {
71
+ return evidence.path === undefined ? evidence.file : `${evidence.file}:${evidence.path}`;
72
+ }
63
73
  /**
64
74
  * Wrap prose to a column without breaking words.
65
75
  * @param text - the prose.
@@ -121,9 +131,19 @@ function describeFileSet(facts) {
121
131
  */
122
132
  function renderFacts(report, paint) {
123
133
  const { facts } = report;
134
+ const provenance = report.target.registry;
124
135
  const rows = [
125
136
  ['package', `${facts.packageName}@${facts.packageVersion}${facts.license === null ? '' : ` (${facts.license})`}`],
126
137
  ['read from', `${report.target.kind} ${report.target.path}`],
138
+ ...provenance === undefined
139
+ ? []
140
+ : [
141
+ ['fetched from', `${provenance.tarball} (${provenance.tarballBytes} bytes, never written to disk)`],
142
+ ['verified', `${provenance.digest} matched dist.integrity before anything parsed it`],
143
+ ['install script', provenance.hasInstallScript
144
+ ? 'yes — the registry marks this package as running one at install time'
145
+ : 'no — the registry does not mark this package as running one'],
146
+ ],
127
147
  ['mounted layer', facts.mountsAsBundle
128
148
  ? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
129
149
  : 'no — installs as a plain library, and dsh plugin add prints a warning saying so'],