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.
@@ -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'],
package/lib/source.js CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { createReadStream, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
22
22
  import { join, posix, relative, resolve, sep } from 'node:path';
23
- import { PassThrough, Transform } from 'node:stream';
23
+ import { PassThrough, Readable, Transform } from 'node:stream';
24
24
  import { pipeline } from 'node:stream/promises';
25
25
  import { createGunzip } from 'node:zlib';
26
26
  import { Parser } from 'tar';
@@ -249,10 +249,11 @@ function isGzip(file) {
249
249
  * `await`, so every one of them is captured here and rethrown on the awaited
250
250
  * path — an emitter-thrown `RangeError` that escapes becomes an uncaught
251
251
  * exception and a raw stack trace on a user's terminal.
252
- * @param file - absolute path to the `.tgz`.
252
+ * @param bytes - the arriving tar stream, gzipped or not.
253
+ * @param gzipped - whether to inflate before parsing.
253
254
  * @param collector - the shared accumulator.
254
255
  */
255
- async function readTarball(file, collector) {
256
+ async function readTarStream(bytes, gzipped, collector) {
256
257
  const pending = [];
257
258
  let failure = null;
258
259
  const parser = new Parser({
@@ -306,8 +307,8 @@ async function readTarball(file, collector) {
306
307
  }));
307
308
  },
308
309
  });
309
- const inflate = isGzip(file) ? createGunzip() : new PassThrough();
310
- await pipeline(createReadStream(file), inflate, byteCeiling(MAX_STREAM_BYTES), parser);
310
+ const inflate = gzipped ? createGunzip() : new PassThrough();
311
+ await pipeline(bytes, inflate, byteCeiling(MAX_STREAM_BYTES), parser);
311
312
  await Promise.all(pending);
312
313
  if (failure !== null)
313
314
  throw failure;
@@ -384,17 +385,58 @@ export async function loadSource(target, limits = DEFAULT_LIMITS) {
384
385
  }
385
386
  else {
386
387
  published = TARBALL_PUBLISH_SET;
387
- try {
388
- await readTarball(path, collector);
389
- }
390
- catch (error) {
391
- const detail = error instanceof Error ? error.message : String(error);
392
- throw new SourceError(`cannot read tarball ${path}: ${detail}`);
393
- }
394
- if (collector.entries === 0) {
395
- throw new SourceError(`${path} holds no tar entries this is not a readable npm tarball`);
396
- }
388
+ await decodeTar(createReadStream(path), isGzip(path), collector, path);
389
+ }
390
+ return finish(kind, path, collector, published);
391
+ }
392
+ /**
393
+ * Decode an npm tarball held in memory, for bytes that never touched the disk.
394
+ *
395
+ * The only caller is the `--from-npm` path, which fetches a tarball and
396
+ * verifies its `dist.integrity` hash before handing the buffer here. Reading it
397
+ * goes through the same `Parser` as the on-disk path, so the "no extraction,
398
+ * ever" property covers both.
399
+ * @param bytes - the complete tarball, gzipped or not.
400
+ * @param label - what to report as the target path, e.g. `npm:pkg@1.2.3`.
401
+ * @param limits - resource ceilings; defaults to {@link DEFAULT_LIMITS}.
402
+ * @returns the decoded package.
403
+ * @throws SourceError when the buffer is not a readable npm tarball.
404
+ */
405
+ export async function loadTarballBuffer(bytes, label, limits = DEFAULT_LIMITS) {
406
+ const collector = { files: new Map(), skipped: [], limits, bytes: 0, entries: 0, unpublished: 0 };
407
+ const gzipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
408
+ await decodeTar(Readable.from(bytes), gzipped, collector, label);
409
+ return finish('registry', label, collector, TARBALL_PUBLISH_SET);
410
+ }
411
+ /**
412
+ * Run the tar pipeline and turn every failure into a `SourceError` naming the
413
+ * target, so a caller can tell "this is not a tarball" from a crash.
414
+ * @param bytes - the arriving tar stream.
415
+ * @param gzipped - whether to inflate before parsing.
416
+ * @param collector - the shared accumulator.
417
+ * @param label - the target as it should appear in an error message.
418
+ */
419
+ async function decodeTar(bytes, gzipped, collector, label) {
420
+ try {
421
+ await readTarStream(bytes, gzipped, collector);
422
+ }
423
+ catch (error) {
424
+ const detail = error instanceof Error ? error.message : String(error);
425
+ throw new SourceError(`cannot read tarball ${label}: ${detail}`);
397
426
  }
427
+ if (collector.entries === 0) {
428
+ throw new SourceError(`${label} holds no tar entries — this is not a readable npm tarball`);
429
+ }
430
+ }
431
+ /**
432
+ * Assemble the decoded package, refusing a target that holds no manifest.
433
+ * @param kind - where the bytes came from.
434
+ * @param path - the target as it should appear in the report.
435
+ * @param collector - the shared accumulator.
436
+ * @param published - the publish-set membership test that was used.
437
+ * @returns the decoded package.
438
+ */
439
+ function finish(kind, path, collector, published) {
398
440
  if (!collector.files.has('package.json')) {
399
441
  throw new SourceError(`no readable package.json in ${path} — this is not an npm package`);
400
442
  }
@@ -15,13 +15,25 @@ export declare const EXIT: {
15
15
  readonly findings: 1;
16
16
  readonly unanalysable: 2;
17
17
  };
18
- /** One parsed command line. */
19
- interface Options {
20
- readonly target: string;
18
+ /** Options that do not depend on which target form was given. */
19
+ interface CommonOptions {
20
+ /** Registry base URL, used only in `--from-npm` mode. */
21
+ readonly registry: string;
21
22
  readonly json: boolean;
22
23
  readonly failOn: Severity | 'none';
23
24
  readonly color: boolean;
24
25
  }
26
+ /**
27
+ * One parsed command line. Exactly one of the two target forms is present:
28
+ * a local path, or a registry spec that opts this invocation into fetching.
29
+ */
30
+ type Options = CommonOptions & ({
31
+ readonly target: string;
32
+ readonly fromNpm: null;
33
+ } | {
34
+ readonly target: null;
35
+ readonly fromNpm: string;
36
+ });
25
37
  /** Raised for a malformed command line; the message is printed and the tool exits 2. */
26
38
  export declare class UsageError extends Error {
27
39
  }
@@ -13,7 +13,7 @@ export declare function isSourceFile(path: string): boolean;
13
13
  /**
14
14
  * Whether a path is markdown that can reach the model verbatim.
15
15
  *
16
- * The reach is conditional and PLAN.md §6.1 says so: a `SKILL.md` inside an npm
16
+ * The reach is conditional: a `SKILL.md` inside an npm
17
17
  * package is only discovered when the plugin registers it through
18
18
  * `ctx.skills`, when a patch row redirects a skill root into the package, or
19
19
  * when something copies it into the user's workspace. This predicate answers
@@ -11,13 +11,15 @@
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.ts';
14
+ export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from './inspect.ts';
15
+ export { inspectFromNpm, precheck } from './npm.ts';
16
+ export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, type PackageSpec, type RegistryOptions, type ResolvedPackage, type VerifiedTarball, } from './registry.ts';
15
17
  export { renderHuman, renderJson } from './report.ts';
16
18
  export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, type ExpressionClass, type ExpressionSite, type ExpressionSlot, type InsertedRow, type JsExprNode, type OverridePatch, type PatchDocument, } from './cordis-yaml.ts';
17
19
  export { declaredPackages, ManifestError, parseManifest, type PackageManifest } from './manifest.ts';
18
- export { DEFAULT_LIMITS, loadSource, SourceError, type PluginSource, type ReadLimits } from './source.ts';
20
+ export { DEFAULT_LIMITS, loadSource, loadTarballBuffer, SourceError, type PluginSource, type ReadLimits, type SourceKind, } from './source.ts';
19
21
  export { globMatch, publishSet, type PublishBasis, type PublishInputs, type PublishSet } from './publish.ts';
20
22
  export { INJECTION_RULES, scanInjection, type InjectionMatch, type InjectionRule } from './injection.ts';
21
- export { compareFindings, SEVERITIES, SEVERITY_RANK, summarize, type AnalysisIntegrity, type Confidence, type Evidence, type Facts, type Finding, type Report, type Severity, type Tier, } from './model.ts';
23
+ export { aggregateFindings, compareFindings, MAX_EXAMPLES, SEVERITIES, SEVERITY_RANK, summarize, type AnalysisIntegrity, type Confidence, type Evidence, type Facts, type Finding, type RegistryProvenance, type Report, type Severity, type Tier, } from './model.ts';
22
24
  export { CORE_ROWS, CORE_ROW_IDS, HARNESS_BUNDLE_PACKAGES, HARNESS_REFERENCE, SEAM_KEYS, SECURITY_ROW_IDS, WATERFALL_EVENTS, type BundleName, type CoreRow, } from './knowledge.ts';
23
25
  //# sourceMappingURL=index.d.ts.map
@@ -9,18 +9,31 @@
9
9
  * `cordis-yaml.ts`, and its result is discarded without being called.
10
10
  * @module dsh-plugin-inspector/inspect
11
11
  */
12
- import { type Report, type Severity } from './model.ts';
12
+ import { type RegistryProvenance, type Report, type Severity } from './model.ts';
13
+ import { type PluginSource } from './source.ts';
13
14
  /** This tool's own version, reported in the JSON document. */
14
15
  export declare const TOOL_VERSION = "0.1.0";
15
16
  /** This tool's package name, reported in the JSON document. */
16
17
  export declare const TOOL_NAME = "dsh-plugin-inspector";
17
18
  /**
18
- * Inspect a plugin package.
19
+ * Inspect a plugin package on disk.
20
+ *
21
+ * Nothing on this path opens a socket: neither this module nor anything it
22
+ * imports can reach the registry, which is what makes "a directory or tarball
23
+ * scan never fetches" structural rather than a promise.
19
24
  * @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
20
25
  * @returns the complete report.
21
26
  * @throws SourceError or ManifestError when the target cannot be analysed at all.
22
27
  */
23
28
  export declare function inspect(target: string): Promise<Report>;
29
+ /**
30
+ * Run every check over an already-decoded package.
31
+ * @param source - the decoded package.
32
+ * @param registry - provenance, when the bytes were fetched from a registry.
33
+ * @returns the complete report.
34
+ * @throws ManifestError when the manifest cannot be read.
35
+ */
36
+ export declare function analyze(source: PluginSource, registry?: RegistryProvenance): Report;
24
37
  /**
25
38
  * Whether a report should fail a CI gate.
26
39
  * @param report - the report.
@@ -32,7 +32,22 @@ export interface Evidence {
32
32
  /** A short verbatim excerpt, truncated and single-lined for display. */
33
33
  readonly snippet?: string;
34
34
  }
35
- /** One thing the inspector believes warrants a decision. */
35
+ /**
36
+ * How many example sites one finding carries. Three is enough to show a
37
+ * pattern — one file, a second confirming it is not isolated, a third for
38
+ * spread — and few enough that the report stays readable when a package
39
+ * imports `node:fs` from ninety files.
40
+ */
41
+ export declare const MAX_EXAMPLES = 3;
42
+ /**
43
+ * One thing the inspector believes warrants a decision, in one package.
44
+ *
45
+ * A finding is per package, not per syntax site. A package that imports
46
+ * `node:fs` from eleven files has one B13 finding with `occurrences: 11`, not
47
+ * eleven findings — the eleventh import warrants no decision the first did not
48
+ * already warrant, and a report that lists it eleven times buries the one
49
+ * finding that does.
50
+ */
36
51
  export interface Finding {
37
52
  /** Catalogue id from PLAN.md §6, e.g. `A2`. Stable across releases. */
38
53
  readonly checkId: string;
@@ -41,11 +56,24 @@ export interface Finding {
41
56
  readonly tier: Tier;
42
57
  readonly severity: Severity;
43
58
  readonly confidence: Confidence;
59
+ /**
60
+ * What this finding is about, independent of where it was seen: the module
61
+ * specifier, the row id, the seam name, the matched rule. Two findings that
62
+ * share a `checkId` and a `subject` are the same finding observed twice, and
63
+ * are collapsed into one. Stable across releases, so a gate can allow a
64
+ * specific `B13`/`node:fs` without allowing every `B13`.
65
+ */
66
+ readonly subject: string;
44
67
  /** One line naming what was found. */
45
68
  readonly title: string;
46
69
  /** Why it matters, in terms of what the harness does with the declaration. */
47
70
  readonly detail: string;
71
+ /** The first site the check matched. Always equal to `examples[0]`. */
48
72
  readonly evidence: Evidence;
73
+ /** Up to {@link MAX_EXAMPLES} sites, in the order they were found. */
74
+ readonly examples: readonly Evidence[];
75
+ /** How many sites the check matched in this package. At least 1. */
76
+ readonly occurrences: number;
49
77
  /**
50
78
  * The one-line evasion for this specific check, or `null` when there is none.
51
79
  * Non-null for every Tier B and Tier C check. Carried inside the finding
@@ -119,9 +147,38 @@ export interface AnalysisIntegrity {
119
147
  readonly degradedBy: readonly string[];
120
148
  readonly filesSkipped: readonly SkippedFile[];
121
149
  }
122
- /** The complete inspection result, and the shape of `--json` output. */
150
+ /**
151
+ * Where a `--from-npm` target came from and how the bytes were checked.
152
+ *
153
+ * Present only in registry mode. Its presence in a report is the record that
154
+ * this invocation opened a socket, which a directory or tarball scan never
155
+ * does.
156
+ */
157
+ export interface RegistryProvenance {
158
+ /** The spec as the user typed it. */
159
+ readonly spec: string;
160
+ /** The registry base URL the packument was read from. */
161
+ readonly registry: string;
162
+ readonly resolvedVersion: string;
163
+ readonly tarball: string;
164
+ /** The digest that matched, in the registry's own encoding. */
165
+ readonly digest: string;
166
+ /** The algorithm that digest was taken with. `sha1` means no `dist.integrity` was published. */
167
+ readonly algorithm: string;
168
+ /** The registry's own flag, read before the tarball was fetched. */
169
+ readonly hasInstallScript: boolean;
170
+ readonly metadataBytes: number;
171
+ readonly tarballBytes: number;
172
+ }
173
+ /**
174
+ * The complete inspection result, and the shape of `--json` output.
175
+ *
176
+ * Version 2 is the first release where a finding is per package rather than per
177
+ * syntax site: every finding gained `subject`, `examples` and `occurrences`,
178
+ * and `target.kind` gained `registry`.
179
+ */
123
180
  export interface Report {
124
- readonly schemaVersion: 1;
181
+ readonly schemaVersion: 2;
125
182
  readonly tool: {
126
183
  readonly name: string;
127
184
  readonly version: string;
@@ -133,8 +190,10 @@ export interface Report {
133
190
  readonly harnessReference: string;
134
191
  };
135
192
  readonly target: {
136
- readonly kind: 'directory' | 'tarball';
193
+ readonly kind: 'directory' | 'tarball' | 'registry';
137
194
  readonly path: string;
195
+ /** Present only when the bytes were fetched from a registry. */
196
+ readonly registry?: RegistryProvenance;
138
197
  };
139
198
  readonly facts: Facts;
140
199
  readonly analysis: AnalysisIntegrity;
@@ -142,14 +201,33 @@ export interface Report {
142
201
  readonly findings: readonly Finding[];
143
202
  }
144
203
  /**
145
- * Order findings for display: most severe first, then by tier (A before B
146
- * before C, since A carries verdicts), then by check id, then by evidence
147
- * location. Total and deterministic, so two runs diff cleanly.
204
+ * Order findings for display: verdicts first, then most severe, then by tier,
205
+ * then by check id, then by evidence location. Total and deterministic, so two
206
+ * runs diff cleanly.
207
+ *
208
+ * Tier A outranks every Tier B finding whatever their severities, because a
209
+ * verdict and a capability report answer different questions and the verdict is
210
+ * the one the reader came for. Within each group severity decides.
148
211
  * @param a - left finding.
149
212
  * @param b - right finding.
150
213
  * @returns negative when `a` sorts first.
151
214
  */
152
215
  export declare function compareFindings(a: Finding, b: Finding): number;
216
+ /**
217
+ * Collapse per-site findings into one finding per check per subject.
218
+ *
219
+ * This is where the report stops being a list of syntax sites and becomes a
220
+ * list of decisions. The count and the examples are kept, so nothing a reader
221
+ * would act on is lost: the number says how widespread it is, the examples say
222
+ * where to look first, and both live in the finding rather than in a footnote.
223
+ *
224
+ * Order is preserved: the first occurrence of each group decides where the
225
+ * group sits, and `compareFindings` sorts afterwards.
226
+ * @param findings - findings as the checks emitted them, one per site.
227
+ * @param maxExamples - how many sites to keep; defaults to {@link MAX_EXAMPLES}.
228
+ * @returns one finding per `checkId` and `subject`.
229
+ */
230
+ export declare function aggregateFindings(findings: readonly Finding[], maxExamples?: number): Finding[];
153
231
  /**
154
232
  * Count findings per severity, including zeroes, so the JSON summary has a
155
233
  * fixed key set that consumers can rely on.