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/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/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.
|
|
@@ -0,0 +1,40 @@
|
|
|
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 type { Report } from './model.ts';
|
|
19
|
+
import { type RegistryOptions, type ResolvedPackage } from './registry.ts';
|
|
20
|
+
/**
|
|
21
|
+
* The metadata pre-check, which needs no tarball.
|
|
22
|
+
*
|
|
23
|
+
* A caller sweeping many packages can read this for each of them at a few
|
|
24
|
+
* kilobytes apiece and decide which ones are worth downloading.
|
|
25
|
+
* @param spec - `<name>` or `<name>@<version>`.
|
|
26
|
+
* @param options - where to fetch from.
|
|
27
|
+
* @returns what the version document says.
|
|
28
|
+
* @throws RegistryError when the package or version does not resolve.
|
|
29
|
+
*/
|
|
30
|
+
export declare function precheck(spec: string, options?: RegistryOptions): Promise<ResolvedPackage>;
|
|
31
|
+
/**
|
|
32
|
+
* Fetch a published package and inspect it in memory.
|
|
33
|
+
* @param spec - `<name>` or `<name>@<version>`; no version means the `latest` tag.
|
|
34
|
+
* @param options - where to fetch from.
|
|
35
|
+
* @returns the complete report, carrying the registry provenance.
|
|
36
|
+
* @throws RegistryError when the package cannot be resolved, fetched, or verified.
|
|
37
|
+
* @throws SourceError or ManifestError when the fetched tarball is not a package.
|
|
38
|
+
*/
|
|
39
|
+
export declare function inspectFromNpm(spec: string, options?: RegistryOptions): Promise<Report>;
|
|
40
|
+
//# sourceMappingURL=npm.d.ts.map
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
/** The public npm registry, used when no other is named. */
|
|
22
|
+
export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
23
|
+
/** Largest packument the tool will read. A version document is a few kilobytes. */
|
|
24
|
+
export declare const MAX_METADATA_BYTES: number;
|
|
25
|
+
/**
|
|
26
|
+
* Largest compressed tarball the tool will download. The decompressed stream is
|
|
27
|
+
* capped separately, and lower, by the tar reader.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MAX_TARBALL_BYTES: number;
|
|
30
|
+
/** Thrown when a package cannot be resolved, fetched, or verified. */
|
|
31
|
+
export declare class RegistryError extends Error {
|
|
32
|
+
}
|
|
33
|
+
/** One `<name>` or `<name>@<version>` argument, split. */
|
|
34
|
+
export interface PackageSpec {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/** A version or dist-tag, or `null` to mean the `latest` tag. */
|
|
37
|
+
readonly version: string | null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What the packument says, before anything is downloaded. Every field here
|
|
41
|
+
* costs one small request and no tarball.
|
|
42
|
+
*/
|
|
43
|
+
export interface ResolvedPackage {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly tarball: string;
|
|
47
|
+
/** The registry's own SRI string, e.g. `sha512-…`, or `null` on old packages. */
|
|
48
|
+
readonly integrity: string | null;
|
|
49
|
+
/** The legacy SHA-1 digest, the only check available when `integrity` is absent. */
|
|
50
|
+
readonly shasum: string | null;
|
|
51
|
+
/** The registry's own flag, which is set when npm would run an install script. */
|
|
52
|
+
readonly hasInstallScript: boolean;
|
|
53
|
+
/** Install lifecycle script names the manifest declares. */
|
|
54
|
+
readonly lifecycleScripts: readonly string[];
|
|
55
|
+
/** The `dsh.bundle.patch` value, which is what makes a package a mounted layer. */
|
|
56
|
+
readonly bundlePatch: string | null;
|
|
57
|
+
/** Bytes of metadata read to learn all of the above. */
|
|
58
|
+
readonly metadataBytes: number;
|
|
59
|
+
}
|
|
60
|
+
/** How a fetched tarball was verified. */
|
|
61
|
+
export interface VerifiedTarball {
|
|
62
|
+
readonly bytes: Buffer;
|
|
63
|
+
/** The algorithm the digest was taken with, e.g. `sha512`. */
|
|
64
|
+
readonly algorithm: string;
|
|
65
|
+
/** The digest that matched, in the registry's own encoding. */
|
|
66
|
+
readonly digest: string;
|
|
67
|
+
}
|
|
68
|
+
/** Where to fetch from. */
|
|
69
|
+
export interface RegistryOptions {
|
|
70
|
+
/** Registry base URL, without a trailing slash. Defaults to {@link DEFAULT_REGISTRY}. */
|
|
71
|
+
readonly registry?: string;
|
|
72
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
73
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Split a `<name>` or `<name>@<version>` argument.
|
|
77
|
+
*
|
|
78
|
+
* The `@` that starts a scope is not a separator, so `@scope/name` has no
|
|
79
|
+
* version and `@scope/name@1.2.3` has one.
|
|
80
|
+
* @param spec - the argument as typed.
|
|
81
|
+
* @returns the package name and the requested version, if any.
|
|
82
|
+
* @throws RegistryError when the name or version is not one the registry accepts.
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseSpec(spec: string): PackageSpec;
|
|
85
|
+
/**
|
|
86
|
+
* Read the packument for one version, which is the pre-check.
|
|
87
|
+
* @param spec - the package name and requested version.
|
|
88
|
+
* @param options - where to fetch from.
|
|
89
|
+
* @returns everything the metadata says, including the tarball URL and its hash.
|
|
90
|
+
* @throws RegistryError when the package or version does not resolve, or when
|
|
91
|
+
* the document does not carry a tarball URL on the registry's own origin.
|
|
92
|
+
*/
|
|
93
|
+
export declare function resolvePackage(spec: PackageSpec, options?: RegistryOptions): Promise<ResolvedPackage>;
|
|
94
|
+
/**
|
|
95
|
+
* Check downloaded bytes against what the registry published.
|
|
96
|
+
*
|
|
97
|
+
* `dist.integrity` is preferred and is a real check. `dist.shasum` is SHA-1 and
|
|
98
|
+
* is accepted only when there is no `integrity` field at all, which happens on
|
|
99
|
+
* packages published before npm 5; it is recorded in the report as the weaker
|
|
100
|
+
* check it is. No digest at all is a refusal, because "verified" would then be
|
|
101
|
+
* a claim the tool cannot make.
|
|
102
|
+
* @param bytes - the downloaded tarball.
|
|
103
|
+
* @param resolved - what the packument said about it.
|
|
104
|
+
* @returns the algorithm and digest that matched.
|
|
105
|
+
* @throws RegistryError on a mismatch, an unusable digest, or no digest at all.
|
|
106
|
+
*/
|
|
107
|
+
export declare function verifyIntegrity(bytes: Buffer, resolved: ResolvedPackage): VerifiedTarball;
|
|
108
|
+
/**
|
|
109
|
+
* Download a resolved package's tarball and verify it before returning it.
|
|
110
|
+
*
|
|
111
|
+
* Nothing parses the bytes on the way in — they are counted against a ceiling
|
|
112
|
+
* and hashed, and a failed hash throws before any caller can see them.
|
|
113
|
+
* @param resolved - the packument reading for the version to fetch.
|
|
114
|
+
* @param options - where to fetch from.
|
|
115
|
+
* @returns the verified tarball, in memory.
|
|
116
|
+
* @throws RegistryError on a transport failure, an oversized body, or a hash mismatch.
|
|
117
|
+
*/
|
|
118
|
+
export declare function fetchVerifiedTarball(resolved: ResolvedPackage, options?: RegistryOptions): Promise<VerifiedTarball>;
|
|
119
|
+
//# sourceMappingURL=registry.d.ts.map
|
package/lib/types/source.d.ts
CHANGED
|
@@ -37,9 +37,15 @@ export declare const DEFAULT_LIMITS: ReadLimits;
|
|
|
37
37
|
/** Thrown when the target cannot be read at all, which is exit code 2, not a finding. */
|
|
38
38
|
export declare class SourceError extends Error {
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Where the analysed bytes came from. `registry` is a tarball too — the same
|
|
42
|
+
* in-memory decoding, over bytes fetched by `--from-npm` instead of read from
|
|
43
|
+
* disk — and is kept distinct so a report says which one it was.
|
|
44
|
+
*/
|
|
45
|
+
export type SourceKind = 'directory' | 'tarball' | 'registry';
|
|
40
46
|
/** The analysed package, decoded into memory. */
|
|
41
47
|
export interface PluginSource {
|
|
42
|
-
readonly kind:
|
|
48
|
+
readonly kind: SourceKind;
|
|
43
49
|
/** The target as the user gave it, resolved to an absolute path. */
|
|
44
50
|
readonly path: string;
|
|
45
51
|
/** Package-relative POSIX path to UTF-8 text, for every readable text file. */
|
|
@@ -68,4 +74,18 @@ export declare const MAX_STREAM_BYTES: number;
|
|
|
68
74
|
* tarball, or holds no `package.json`.
|
|
69
75
|
*/
|
|
70
76
|
export declare function loadSource(target: string, limits?: ReadLimits): Promise<PluginSource>;
|
|
77
|
+
/**
|
|
78
|
+
* Decode an npm tarball held in memory, for bytes that never touched the disk.
|
|
79
|
+
*
|
|
80
|
+
* The only caller is the `--from-npm` path, which fetches a tarball and
|
|
81
|
+
* verifies its `dist.integrity` hash before handing the buffer here. Reading it
|
|
82
|
+
* goes through the same `Parser` as the on-disk path, so the "no extraction,
|
|
83
|
+
* ever" property covers both.
|
|
84
|
+
* @param bytes - the complete tarball, gzipped or not.
|
|
85
|
+
* @param label - what to report as the target path, e.g. `npm:pkg@1.2.3`.
|
|
86
|
+
* @param limits - resource ceilings; defaults to {@link DEFAULT_LIMITS}.
|
|
87
|
+
* @returns the decoded package.
|
|
88
|
+
* @throws SourceError when the buffer is not a readable npm tarball.
|
|
89
|
+
*/
|
|
90
|
+
export declare function loadTarballBuffer(bytes: Buffer, label: string, limits?: ReadLimits): Promise<PluginSource>;
|
|
71
91
|
//# sourceMappingURL=source.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-inspector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Know what a DeepSeek Harness plugin does before you install it — static pre-install analysis of a plugin directory or tarball",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"test": "vitest run --config vitest.config.ts",
|
|
57
57
|
"test:coverage": "vitest run --config vitest.config.ts --coverage",
|
|
58
58
|
"inspect": "node --experimental-strip-types src/cli.ts",
|
|
59
|
+
"sweep": "node --experimental-strip-types scripts/ecosystem-sweep.ts",
|
|
59
60
|
"test:e2e": "pnpm run build && vitest run --config vitest.e2e.config.ts"
|
|
60
61
|
}
|
|
61
62
|
}
|