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.
- package/README.md +105 -28
- 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/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/index.d.ts +5 -3
- package/lib/types/inspect.d.ts +15 -2
- package/lib/types/model.d.ts +85 -7
- package/lib/types/npm.d.ts +40 -0
- package/lib/types/registry.d.ts +119 -0
- package/lib/types/source.d.ts +21 -1
- package/package.json +2 -1
package/lib/checks/tier-c.js
CHANGED
|
@@ -33,7 +33,7 @@ const NAMED_TARGET_CALLEES = new Set([
|
|
|
33
33
|
* @returns the complete finding.
|
|
34
34
|
*/
|
|
35
35
|
function tierC(finding) {
|
|
36
|
-
return { ...finding, tier: 'C', confidence: 'moderate' };
|
|
36
|
+
return { ...finding, tier: 'C', confidence: 'moderate', examples: [finding.evidence], occurrences: 1 };
|
|
37
37
|
}
|
|
38
38
|
/** C1 — source that is not written to be read. */
|
|
39
39
|
function checkMinification(input) {
|
|
@@ -57,12 +57,13 @@ function checkMinification(input) {
|
|
|
57
57
|
findings.push(tierC({
|
|
58
58
|
checkId: 'C1',
|
|
59
59
|
name: 'minified-source',
|
|
60
|
+
subject: 'minified-source',
|
|
60
61
|
severity: 'medium',
|
|
61
|
-
title:
|
|
62
|
-
detail: `
|
|
63
|
-
+
|
|
64
|
-
+ 'syntax, and it reads minified syntax no better than a person does. Every Tier B
|
|
65
|
-
+ 'package is unreliable while
|
|
62
|
+
title: 'Ships source that is minified or generated',
|
|
63
|
+
detail: `In \`${path}\` the longest line is ${longest} characters across ${lines.length} line(s), and lines `
|
|
64
|
+
+ `that long are ${Math.round(longBytes * 100 / Math.max(text.length, 1))}% of the file. Capability `
|
|
65
|
+
+ 'detection reads syntax, and it reads minified syntax no better than a person does. Every Tier B '
|
|
66
|
+
+ 'negative for this package is unreliable while a file like this is in it.',
|
|
66
67
|
evidence: { file: path, path: '1:1', snippet: snippet(lines[0] ?? '') },
|
|
67
68
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
|
68
69
|
}));
|
|
@@ -81,8 +82,9 @@ function checkDynamicDispatch(input) {
|
|
|
81
82
|
findings.push(tierC({
|
|
82
83
|
checkId: 'C2',
|
|
83
84
|
name: 'dynamic-dispatch',
|
|
85
|
+
subject: what,
|
|
84
86
|
severity: 'high',
|
|
85
|
-
title:
|
|
87
|
+
title: `Shipped source ${what}`,
|
|
86
88
|
detail: 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of them, so no '
|
|
87
89
|
+ 'Tier B negative for this package carries any information. A Tier B positive still does — the tool saw '
|
|
88
90
|
+ 'what it saw.',
|
|
@@ -209,6 +211,7 @@ function checkSourcelessBuild(input) {
|
|
|
209
211
|
findings.push(tierC({
|
|
210
212
|
checkId: 'C3',
|
|
211
213
|
name: 'sourceless-build-output',
|
|
214
|
+
subject: 'no-authored-source',
|
|
212
215
|
severity: 'low',
|
|
213
216
|
title: `Ships ${built.length} built file(s) and no source`,
|
|
214
217
|
detail: 'What runs is the built output, so that is what this tool analysed — but there is nothing in the '
|
|
@@ -222,8 +225,9 @@ function checkSourcelessBuild(input) {
|
|
|
222
225
|
findings.push(tierC({
|
|
223
226
|
checkId: 'C6',
|
|
224
227
|
name: 'minified-artifact',
|
|
228
|
+
subject: 'min-js',
|
|
225
229
|
severity: 'low',
|
|
226
|
-
title:
|
|
230
|
+
title: 'Ships a minified artifact',
|
|
227
231
|
detail: 'A `.min.js` file is output, not source. It was still parsed, but nothing about its readability '
|
|
228
232
|
+ 'supports a confident negative.',
|
|
229
233
|
evidence: { file: path },
|
|
@@ -244,6 +248,7 @@ function checkUnreadableFiles(input) {
|
|
|
244
248
|
return [...byReason].map(([reason, paths]) => tierC({
|
|
245
249
|
checkId: 'C4',
|
|
246
250
|
name: 'unreadable-payload',
|
|
251
|
+
subject: reason,
|
|
247
252
|
severity: reason === 'binary' ? 'medium' : 'low',
|
|
248
253
|
title: `${paths.length} file(s) were not analysed (${reason})`,
|
|
249
254
|
detail: reason === 'binary'
|
|
@@ -259,6 +264,7 @@ function checkPatchWalkLimit(input) {
|
|
|
259
264
|
return input.patches.filter(patch => patch.limit !== null).map(patch => tierC({
|
|
260
265
|
checkId: 'C5',
|
|
261
266
|
name: 'patch-walk-truncated',
|
|
267
|
+
subject: patch.file,
|
|
262
268
|
severity: 'high',
|
|
263
269
|
title: `\`${patch.file}\` was only read in part (${patch.limit === 'depth' ? 'nesting' : 'node count'} ceiling)`,
|
|
264
270
|
detail: patch.limit === 'depth'
|
package/lib/cli.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import process from 'node:process';
|
|
12
12
|
import { exceedsThreshold, inspect, TOOL_VERSION } from "./inspect.js";
|
|
13
13
|
import { SEVERITY_RANK } from "./model.js";
|
|
14
|
+
import { inspectFromNpm } from "./npm.js";
|
|
15
|
+
import { DEFAULT_REGISTRY } from "./registry.js";
|
|
14
16
|
import { renderHuman, renderJson } from "./report.js";
|
|
15
17
|
/** Exit codes this tool uses. */
|
|
16
18
|
export const EXIT = {
|
|
@@ -22,11 +24,18 @@ const USAGE = `dsh-inspect — know what a DeepSeek Harness plugin does before y
|
|
|
22
24
|
|
|
23
25
|
Usage
|
|
24
26
|
dsh-inspect <target> [options]
|
|
27
|
+
dsh-inspect --from-npm <name>[@<version>] [options]
|
|
25
28
|
|
|
26
29
|
<target> A plugin directory, or an npm tarball (.tgz / .tar.gz).
|
|
27
30
|
Nothing in the target is installed, built, or executed.
|
|
28
31
|
|
|
29
32
|
Options
|
|
33
|
+
--from-npm <spec> Fetch a published package from the registry, verify its
|
|
34
|
+
dist.integrity hash, and analyse it in memory. This is
|
|
35
|
+
the only mode that opens a socket, and it never runs
|
|
36
|
+
npm, writes to disk, or executes an install script.
|
|
37
|
+
--registry <url> Registry base URL for --from-npm.
|
|
38
|
+
(default: ${DEFAULT_REGISTRY})
|
|
30
39
|
--json Emit the machine-readable JSON document on stdout.
|
|
31
40
|
--fail-on <severity> Exit 1 at or above this severity.
|
|
32
41
|
critical | high | medium | low | none (default: high)
|
|
@@ -40,7 +49,7 @@ Exit codes
|
|
|
40
49
|
2 analysis could not be performed
|
|
41
50
|
|
|
42
51
|
To inspect a published package without installing it:
|
|
43
|
-
npm
|
|
52
|
+
dsh-inspect --from-npm <name>@<version>
|
|
44
53
|
`;
|
|
45
54
|
/** Raised for a malformed command line; the message is printed and the tool exits 2. */
|
|
46
55
|
export class UsageError extends Error {
|
|
@@ -53,9 +62,23 @@ export class UsageError extends Error {
|
|
|
53
62
|
*/
|
|
54
63
|
export function parseArgs(argv) {
|
|
55
64
|
let target = null;
|
|
65
|
+
let fromNpm = null;
|
|
66
|
+
let registry = DEFAULT_REGISTRY;
|
|
56
67
|
let json = false;
|
|
57
68
|
let failOn = 'high';
|
|
58
69
|
let color = process.stdout.isTTY === true;
|
|
70
|
+
/**
|
|
71
|
+
* Read the value of an option that takes one.
|
|
72
|
+
* @param index - the option's own position in argv.
|
|
73
|
+
* @param option - the option name, for the error message.
|
|
74
|
+
* @returns the value.
|
|
75
|
+
*/
|
|
76
|
+
const value = (index, option) => {
|
|
77
|
+
const next = argv[index + 1];
|
|
78
|
+
if (next === undefined)
|
|
79
|
+
throw new UsageError(`${option} needs a value`);
|
|
80
|
+
return next;
|
|
81
|
+
};
|
|
59
82
|
for (let index = 0; index < argv.length; index += 1) {
|
|
60
83
|
const argument = argv[index] ?? '';
|
|
61
84
|
if (argument === '--help' || argument === '-h') {
|
|
@@ -78,15 +101,25 @@ export function parseArgs(argv) {
|
|
|
78
101
|
color = true;
|
|
79
102
|
continue;
|
|
80
103
|
}
|
|
104
|
+
if (argument === '--from-npm') {
|
|
105
|
+
if (fromNpm !== null)
|
|
106
|
+
throw new UsageError('only one package may be fetched at a time');
|
|
107
|
+
fromNpm = value(index, '--from-npm');
|
|
108
|
+
index += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (argument === '--registry') {
|
|
112
|
+
registry = value(index, '--registry');
|
|
113
|
+
index += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
81
116
|
if (argument === '--fail-on') {
|
|
82
|
-
const
|
|
117
|
+
const severity = value(index, '--fail-on');
|
|
83
118
|
index += 1;
|
|
84
|
-
if (
|
|
85
|
-
throw new UsageError(
|
|
86
|
-
if (value !== 'none' && !(value in SEVERITY_RANK)) {
|
|
87
|
-
throw new UsageError(`--fail-on must be one of critical, high, medium, low, none — got ${value}`);
|
|
119
|
+
if (severity !== 'none' && !(severity in SEVERITY_RANK)) {
|
|
120
|
+
throw new UsageError(`--fail-on must be one of critical, high, medium, low, none — got ${severity}`);
|
|
88
121
|
}
|
|
89
|
-
failOn =
|
|
122
|
+
failOn = severity;
|
|
90
123
|
continue;
|
|
91
124
|
}
|
|
92
125
|
if (argument.startsWith('-'))
|
|
@@ -95,9 +128,18 @@ export function parseArgs(argv) {
|
|
|
95
128
|
throw new UsageError('only one target may be inspected at a time');
|
|
96
129
|
target = argument;
|
|
97
130
|
}
|
|
131
|
+
// Fetching is opt-in per invocation and never a fallback: a mistyped path
|
|
132
|
+
// must not become a registry lookup, and a registry spec must not silently
|
|
133
|
+
// shadow a local directory of the same name.
|
|
134
|
+
if (target !== null && fromNpm !== null) {
|
|
135
|
+
throw new UsageError('--from-npm fetches a published package; it cannot be combined with a local target');
|
|
136
|
+
}
|
|
137
|
+
const common = { registry, json, failOn, color };
|
|
138
|
+
if (fromNpm !== null)
|
|
139
|
+
return { ...common, target: null, fromNpm };
|
|
98
140
|
if (target === null)
|
|
99
|
-
throw new UsageError('a target directory or tarball is required');
|
|
100
|
-
return {
|
|
141
|
+
throw new UsageError('a target directory or tarball is required, or --from-npm <name>');
|
|
142
|
+
return { ...common, target, fromNpm: null };
|
|
101
143
|
}
|
|
102
144
|
/**
|
|
103
145
|
* Run one invocation.
|
|
@@ -116,7 +158,9 @@ export async function main(argv) {
|
|
|
116
158
|
if (options === null)
|
|
117
159
|
return EXIT.clean;
|
|
118
160
|
try {
|
|
119
|
-
const report =
|
|
161
|
+
const report = options.fromNpm === null
|
|
162
|
+
? await inspect(options.target)
|
|
163
|
+
: await inspectFromNpm(options.fromNpm, { registry: options.registry });
|
|
120
164
|
process.stdout.write(options.json ? renderJson(report) : renderHuman(report, options.color));
|
|
121
165
|
return exceedsThreshold(report, options.failOn) ? EXIT.findings : EXIT.clean;
|
|
122
166
|
}
|
package/lib/index.js
CHANGED
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
* The ceiling is triage, not containment. See `README.md` §Limitations.
|
|
12
12
|
* @module dsh-plugin-inspector
|
|
13
13
|
*/
|
|
14
|
-
export { exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
|
|
14
|
+
export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
|
|
15
|
+
export { inspectFromNpm, precheck } from "./npm.js";
|
|
16
|
+
export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
|
|
15
17
|
export { renderHuman, renderJson } from "./report.js";
|
|
16
18
|
export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, } from "./cordis-yaml.js";
|
|
17
19
|
export { declaredPackages, ManifestError, parseManifest } from "./manifest.js";
|
|
18
|
-
export { DEFAULT_LIMITS, loadSource, SourceError } from "./source.js";
|
|
20
|
+
export { DEFAULT_LIMITS, loadSource, loadTarballBuffer, SourceError, } from "./source.js";
|
|
19
21
|
export { globMatch, publishSet } from "./publish.js";
|
|
20
22
|
export { INJECTION_RULES, scanInjection } from "./injection.js";
|
|
21
|
-
export { compareFindings, SEVERITIES, SEVERITY_RANK, summarize, } from "./model.js";
|
|
23
|
+
export { aggregateFindings, compareFindings, MAX_EXAMPLES, SEVERITIES, SEVERITY_RANK, summarize, } from "./model.js";
|
|
22
24
|
export { CORE_ROWS, CORE_ROW_IDS, HARNESS_BUNDLE_PACKAGES, HARNESS_REFERENCE, SEAM_KEYS, SECURITY_ROW_IDS, WATERFALL_EVENTS, } from "./knowledge.js";
|
package/lib/inspect.js
CHANGED
|
@@ -13,7 +13,7 @@ import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cord
|
|
|
13
13
|
import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
|
|
14
14
|
import { HARNESS_REFERENCE } from "./knowledge.js";
|
|
15
15
|
import { parseManifest } from "./manifest.js";
|
|
16
|
-
import { SEVERITY_RANK, compareFindings, summarize, } from "./model.js";
|
|
16
|
+
import { SEVERITY_RANK, aggregateFindings, compareFindings, summarize, } from "./model.js";
|
|
17
17
|
import { loadSource } from "./source.js";
|
|
18
18
|
import { runTierA } from "./checks/tier-a.js";
|
|
19
19
|
import { runTierB } from "./checks/tier-b.js";
|
|
@@ -69,13 +69,26 @@ function downgrade(confidence, degraded) {
|
|
|
69
69
|
return 'moderate';
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Inspect a plugin package.
|
|
72
|
+
* Inspect a plugin package on disk.
|
|
73
|
+
*
|
|
74
|
+
* Nothing on this path opens a socket: neither this module nor anything it
|
|
75
|
+
* imports can reach the registry, which is what makes "a directory or tarball
|
|
76
|
+
* scan never fetches" structural rather than a promise.
|
|
73
77
|
* @param target - a plugin directory, or a `.tgz` / `.tar.gz` npm tarball.
|
|
74
78
|
* @returns the complete report.
|
|
75
79
|
* @throws SourceError or ManifestError when the target cannot be analysed at all.
|
|
76
80
|
*/
|
|
77
81
|
export async function inspect(target) {
|
|
78
|
-
|
|
82
|
+
return analyze(await loadSource(target));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Run every check over an already-decoded package.
|
|
86
|
+
* @param source - the decoded package.
|
|
87
|
+
* @param registry - provenance, when the bytes were fetched from a registry.
|
|
88
|
+
* @returns the complete report.
|
|
89
|
+
* @throws ManifestError when the manifest cannot be read.
|
|
90
|
+
*/
|
|
91
|
+
export function analyze(source, registry) {
|
|
79
92
|
const manifest = parseManifest(source.files.get('package.json') ?? '');
|
|
80
93
|
const declared = manifest.dsh.bundle?.patch;
|
|
81
94
|
const mountsAsBundle = declared !== undefined;
|
|
@@ -114,7 +127,10 @@ export async function inspect(target) {
|
|
|
114
127
|
...runTierB(input).map(finding => ({ ...finding, confidence: downgrade(finding.confidence, degraded) })),
|
|
115
128
|
...tierC,
|
|
116
129
|
];
|
|
117
|
-
|
|
130
|
+
// Aggregate before sorting: the report is a list of decisions, one per check
|
|
131
|
+
// per subject, and the count travels inside the finding. A package importing
|
|
132
|
+
// `node:fs` from eleven files states that once.
|
|
133
|
+
const findings = aggregateFindings(raw).sort(compareFindings);
|
|
118
134
|
const facts = {
|
|
119
135
|
packageName: manifest.name,
|
|
120
136
|
packageVersion: manifest.version,
|
|
@@ -141,9 +157,9 @@ export async function inspect(target) {
|
|
|
141
157
|
unpublishedFiles: source.unpublishedFiles,
|
|
142
158
|
};
|
|
143
159
|
return {
|
|
144
|
-
schemaVersion:
|
|
160
|
+
schemaVersion: 2,
|
|
145
161
|
tool: { name: TOOL_NAME, version: TOOL_VERSION, harnessReference: HARNESS_REFERENCE },
|
|
146
|
-
target: { kind: source.kind, path: source.path },
|
|
162
|
+
target: { kind: source.kind, path: source.path, ...registry === undefined ? {} : { registry } },
|
|
147
163
|
facts,
|
|
148
164
|
analysis: {
|
|
149
165
|
integrity: degraded ? 'degraded' : 'complete',
|
package/lib/model.js
CHANGED
|
Binary file
|
package/lib/npm.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--from-npm` — inspecting a published package without installing it.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only path in the tool that reaches a network, and it is
|
|
5
|
+
* separate from `inspect.ts` on purpose: a directory or tarball scan cannot
|
|
6
|
+
* arrive here, because nothing on that path imports this file. The steps are
|
|
7
|
+
* fixed and their order is the guarantee:
|
|
8
|
+
*
|
|
9
|
+
* 1. read the version document (~3 KB) — which already answers
|
|
10
|
+
* `hasInstallScript`, the install lifecycle scripts, and `dsh.bundle`;
|
|
11
|
+
* 2. download the tarball into memory;
|
|
12
|
+
* 3. verify `dist.integrity` **before** anything parses a byte of it;
|
|
13
|
+
* 4. decode in memory and analyse, exactly as the tarball path does.
|
|
14
|
+
*
|
|
15
|
+
* No subprocess, no disk write, no lifecycle script, and no `npm pack`.
|
|
16
|
+
* @module dsh-plugin-inspector/npm
|
|
17
|
+
*/
|
|
18
|
+
import { analyze } from "./inspect.js";
|
|
19
|
+
import { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, resolvePackage, } from "./registry.js";
|
|
20
|
+
import { loadTarballBuffer } from "./source.js";
|
|
21
|
+
/**
|
|
22
|
+
* The metadata pre-check, which needs no tarball.
|
|
23
|
+
*
|
|
24
|
+
* A caller sweeping many packages can read this for each of them at a few
|
|
25
|
+
* kilobytes apiece and decide which ones are worth downloading.
|
|
26
|
+
* @param spec - `<name>` or `<name>@<version>`.
|
|
27
|
+
* @param options - where to fetch from.
|
|
28
|
+
* @returns what the version document says.
|
|
29
|
+
* @throws RegistryError when the package or version does not resolve.
|
|
30
|
+
*/
|
|
31
|
+
export async function precheck(spec, options = {}) {
|
|
32
|
+
return resolvePackage(parseSpec(spec), options);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a published package and inspect it in memory.
|
|
36
|
+
* @param spec - `<name>` or `<name>@<version>`; no version means the `latest` tag.
|
|
37
|
+
* @param options - where to fetch from.
|
|
38
|
+
* @returns the complete report, carrying the registry provenance.
|
|
39
|
+
* @throws RegistryError when the package cannot be resolved, fetched, or verified.
|
|
40
|
+
* @throws SourceError or ManifestError when the fetched tarball is not a package.
|
|
41
|
+
*/
|
|
42
|
+
export async function inspectFromNpm(spec, options = {}) {
|
|
43
|
+
const resolved = await precheck(spec, options);
|
|
44
|
+
const verified = await fetchVerifiedTarball(resolved, options);
|
|
45
|
+
const provenance = {
|
|
46
|
+
spec,
|
|
47
|
+
registry: (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, ''),
|
|
48
|
+
resolvedVersion: resolved.version,
|
|
49
|
+
tarball: resolved.tarball,
|
|
50
|
+
digest: verified.digest,
|
|
51
|
+
algorithm: verified.algorithm,
|
|
52
|
+
hasInstallScript: resolved.hasInstallScript,
|
|
53
|
+
metadataBytes: resolved.metadataBytes,
|
|
54
|
+
tarballBytes: verified.bytes.byteLength,
|
|
55
|
+
};
|
|
56
|
+
const source = await loadTarballBuffer(verified.bytes, `npm:${resolved.name}@${resolved.version}`);
|
|
57
|
+
return analyze(source, provenance);
|
|
58
|
+
}
|
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'],
|