dsh-plugin-inspector 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -54
- package/lib/checks/tier-a.js +28 -2
- package/lib/checks/tier-b.js +62 -5
- package/lib/checks/tier-c.js +14 -8
- package/lib/cli.js +54 -10
- package/lib/files.js +1 -1
- package/lib/index.js +5 -3
- package/lib/inspect.js +22 -6
- package/lib/model.js +0 -0
- package/lib/npm.js +58 -0
- package/lib/registry.js +261 -0
- package/lib/report.js +25 -5
- package/lib/source.js +57 -15
- package/lib/types/cli.d.ts +15 -3
- package/lib/types/files.d.ts +1 -1
- package/lib/types/index.d.ts +5 -3
- package/lib/types/inspect.d.ts +15 -2
- package/lib/types/model.d.ts +85 -7
- package/lib/types/npm.d.ts +40 -0
- package/lib/types/registry.d.ts +119 -0
- package/lib/types/source.d.ts +21 -1
- package/package.json +2 -1
package/lib/registry.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
310
|
-
await pipeline(
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
}
|
package/lib/types/cli.d.ts
CHANGED
|
@@ -15,13 +15,25 @@ export declare const EXIT: {
|
|
|
15
15
|
readonly findings: 1;
|
|
16
16
|
readonly unanalysable: 2;
|
|
17
17
|
};
|
|
18
|
-
/**
|
|
19
|
-
interface
|
|
20
|
-
|
|
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
|
}
|
package/lib/types/files.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
package/lib/types/inspect.d.ts
CHANGED
|
@@ -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.
|
package/lib/types/model.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|