dsh-plugin-inspector 0.6.0 → 0.8.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 +10 -8
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +110 -1
- package/lib/checks/tier-b.js +341 -28
- package/lib/checks/tier-c.js +146 -18
- package/lib/index.js +2 -1
- package/lib/inspect.js +7 -2
- package/lib/knowledge.js +289 -25
- package/lib/npm.js +58 -5
- package/lib/registry.js +54 -0
- package/lib/report.js +58 -7
- package/lib/syntax.js +135 -0
- package/lib/types/attestation.d.ts +173 -0
- package/lib/types/checks/input.d.ts +7 -0
- package/lib/types/checks/tier-c.d.ts +5 -3
- package/lib/types/index.d.ts +2 -1
- package/lib/types/inspect.d.ts +5 -2
- package/lib/types/knowledge.d.ts +212 -4
- package/lib/types/model.d.ts +13 -1
- package/lib/types/npm.d.ts +12 -2
- package/lib/types/registry.d.ts +40 -0
- package/lib/types/syntax.d.ts +51 -0
- package/package.json +2 -1
package/lib/checks/tier-c.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* user is entitled to see it.
|
|
8
8
|
*
|
|
9
9
|
* A Tier C hit also has a mechanical consequence. Tier B recognises a whitelist
|
|
10
|
-
* of syntactic shapes
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* of syntactic shapes — a member, on a receiver, taking a name it can resolve —
|
|
11
|
+
* so when code is minified, when a name is assembled out of something this tool
|
|
12
|
+
* cannot fold, when a member is detached from the receiver the checks match it
|
|
13
|
+
* on, or when the shipped artifact has no readable source, a Tier B *positive*
|
|
14
|
+
* is still true but a Tier B *negative* means nothing. `inspect.ts` reads the
|
|
13
15
|
* output of this module to lower Tier B confidence and to forbid the report
|
|
14
16
|
* from claiming nothing was found.
|
|
15
17
|
* @module dsh-plugin-inspector/checks/tier-c
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
import ts from 'typescript';
|
|
18
20
|
import { lineColumn, snippet } from "../files.js";
|
|
19
21
|
import { MAX_EXAMPLES } from "../model.js";
|
|
22
|
+
import { BUILTIN_MODULE_GETTER, foldConstantString, isBuiltinModuleGetter } from "../syntax.js";
|
|
20
23
|
/** A line longer than this is not written by hand. */
|
|
21
24
|
const MINIFIED_LINE_LENGTH = 500;
|
|
22
25
|
/** Below this many bytes, a low line count says nothing. */
|
|
@@ -27,6 +30,42 @@ const DISPATCH_RECEIVERS = new Set(['ctx', 'context', 'globalThis', 'global']);
|
|
|
27
30
|
const NAMED_TARGET_CALLEES = new Set([
|
|
28
31
|
'on', 'once', 'provide', 'set', 'get', 'emit', 'waterfall', 'bail', 'parallel', 'serial',
|
|
29
32
|
]);
|
|
33
|
+
/**
|
|
34
|
+
* Members every Tier B check can only match *through* their receiver.
|
|
35
|
+
*
|
|
36
|
+
* B1 reads `<receiver>.provide` / `.set` / `.mixin`, B5 reads
|
|
37
|
+
* `<receiver>.on`, B11 reads `.plugin`, B10 reads `tools.register`, and B7,
|
|
38
|
+
* B9 and B13 read `process.getBuiltinModule`. Every one of them is a property
|
|
39
|
+
* access on a named receiver, so pulling the member off the receiver and
|
|
40
|
+
* binding it to a bare name removes the only thing those checks match on —
|
|
41
|
+
* while the call still does exactly what it did.
|
|
42
|
+
*
|
|
43
|
+
* This is not the assembled-name case. The name is right there in plain text;
|
|
44
|
+
* what is gone is the receiver, and following it to the call site is value
|
|
45
|
+
* tracking this tool does not do.
|
|
46
|
+
*/
|
|
47
|
+
const RECEIVER_ANCHORED_MEMBERS = new Set([
|
|
48
|
+
'provide', 'set', 'mixin', 'on', 'plugin', 'register', BUILTIN_MODULE_GETTER,
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Receivers whose members {@link RECEIVER_ANCHORED_MEMBERS} names.
|
|
52
|
+
*
|
|
53
|
+
* The plugin context, plus `process` — the receiver of `getBuiltinModule`. The
|
|
54
|
+
* guard is what keeps `const { set } = options` out of the check.
|
|
55
|
+
*/
|
|
56
|
+
const ANCHORED_RECEIVERS = new Set([...DISPATCH_RECEIVERS, 'process']);
|
|
57
|
+
/** The harness's plugin entry point, whose first parameter is the context. */
|
|
58
|
+
const PLUGIN_ENTRY = 'apply';
|
|
59
|
+
/** Why an unresolvable name makes every Tier B negative meaningless. */
|
|
60
|
+
const ASSEMBLED_NAME_DETAIL = 'Every Tier B check matches a literal name. A name assembled at runtime defeats all of '
|
|
61
|
+
+ 'them, so no Tier B negative for this package carries any information. A Tier B positive still does — the tool '
|
|
62
|
+
+ 'saw what it saw.';
|
|
63
|
+
/** Why a detached member makes every Tier B negative meaningless. */
|
|
64
|
+
const DETACHED_MEMBER_DETAIL = 'Every Tier B check that matches this member matches it on its receiver: B1 reads '
|
|
65
|
+
+ '`ctx.provide`, B5 reads `ctx.on`, B10 reads `tools.register`, B13 reads `process.getBuiltinModule`. Bound to a '
|
|
66
|
+
+ 'bare name the member still does all of that, and the call site no longer says on what. Following the binding is '
|
|
67
|
+
+ 'value tracking this tool does not do, so no Tier B negative for this package carries any information. A Tier B '
|
|
68
|
+
+ 'positive still does — the tool saw what it saw.';
|
|
30
69
|
/**
|
|
31
70
|
* Build one Tier C finding. Confidence is `moderate`: these are heuristics
|
|
32
71
|
* about form, and a hand-written file can legitimately have one long line.
|
|
@@ -85,20 +124,18 @@ function checkMinification(files) {
|
|
|
85
124
|
}
|
|
86
125
|
return findings;
|
|
87
126
|
}
|
|
88
|
-
/** C2 — names the analyzer cannot resolve without running the code. */
|
|
127
|
+
/** C2 — names and receivers the analyzer cannot resolve without running the code. */
|
|
89
128
|
function checkDynamicDispatch(files) {
|
|
90
129
|
const findings = [];
|
|
91
130
|
for (const { path, text, node: source } of files) {
|
|
92
|
-
const report = (node, what) => {
|
|
131
|
+
const report = (node, what, why) => {
|
|
93
132
|
findings.push(tierC({
|
|
94
133
|
checkId: 'C2',
|
|
95
134
|
name: 'dynamic-dispatch',
|
|
96
135
|
subject: what,
|
|
97
136
|
severity: 'high',
|
|
98
137
|
title: `Shipped source ${what}`,
|
|
99
|
-
detail:
|
|
100
|
-
+ 'Tier B negative for this package carries any information. A Tier B positive still does — the tool saw '
|
|
101
|
-
+ 'what it saw.',
|
|
138
|
+
detail: why,
|
|
102
139
|
evidence: {
|
|
103
140
|
file: path,
|
|
104
141
|
path: lineColumn(text, node.getStart(source)),
|
|
@@ -108,29 +145,36 @@ function checkDynamicDispatch(files) {
|
|
|
108
145
|
}));
|
|
109
146
|
};
|
|
110
147
|
const isComputed = (node) => node !== undefined && !ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node);
|
|
148
|
+
// A specifier Tier B folded to a constant is one Tier B matched, so it is
|
|
149
|
+
// not a gap. Only what the folder gives up on degrades the report.
|
|
150
|
+
const isUnresolvedSpecifier = (node) => isComputed(node) && foldConstantString(node) === null;
|
|
111
151
|
const visit = (node) => {
|
|
112
152
|
if (ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)
|
|
113
153
|
&& DISPATCH_RECEIVERS.has(node.expression.text) && isComputed(node.argumentExpression)) {
|
|
114
|
-
report(node, `resolves a member of \`${node.expression.text}\` from a computed name
|
|
154
|
+
report(node, `resolves a member of \`${node.expression.text}\` from a computed name`, ASSEMBLED_NAME_DETAIL);
|
|
115
155
|
}
|
|
156
|
+
if (ts.isVariableDeclaration(node))
|
|
157
|
+
reportDetachedBindings(node, report);
|
|
158
|
+
if (ts.isFunctionLike(node))
|
|
159
|
+
reportDetachedContextParameter(node, report);
|
|
116
160
|
if (ts.isCallExpression(node)) {
|
|
117
161
|
const callee = node.expression;
|
|
118
162
|
const isRequire = ts.isIdentifier(callee) && callee.text === 'require';
|
|
119
163
|
const isImport = callee.kind === ts.SyntaxKind.ImportKeyword;
|
|
120
|
-
if ((isRequire || isImport) &&
|
|
121
|
-
report(node, 'loads a module from a computed specifier');
|
|
164
|
+
if ((isRequire || isImport || isBuiltinModuleGetter(node)) && isUnresolvedSpecifier(node.arguments[0])) {
|
|
165
|
+
report(node, 'loads a module from a computed specifier', ASSEMBLED_NAME_DETAIL);
|
|
122
166
|
}
|
|
123
167
|
if (ts.isIdentifier(callee) && callee.text === 'atob') {
|
|
124
|
-
report(node, 'decodes a base64 string at runtime');
|
|
168
|
+
report(node, 'decodes a base64 string at runtime', ASSEMBLED_NAME_DETAIL);
|
|
125
169
|
}
|
|
126
170
|
if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'from'
|
|
127
171
|
&& ts.isIdentifier(callee.expression) && callee.expression.text === 'Buffer'
|
|
128
172
|
&& (literalOf(node.arguments[1]) === 'base64' || literalOf(node.arguments[1]) === 'base64url')) {
|
|
129
|
-
report(node, 'decodes a base64 string at runtime');
|
|
173
|
+
report(node, 'decodes a base64 string at runtime', ASSEMBLED_NAME_DETAIL);
|
|
130
174
|
}
|
|
131
175
|
if (ts.isPropertyAccessExpression(callee) && NAMED_TARGET_CALLEES.has(callee.name.text)
|
|
132
176
|
&& isDispatchReceiver(callee.expression) && isAssembledName(node.arguments[0])) {
|
|
133
|
-
report(node, `passes an assembled name to \`${receiverName(callee.expression)}.${callee.name.text}()
|
|
177
|
+
report(node, `passes an assembled name to \`${receiverName(callee.expression)}.${callee.name.text}()\``, ASSEMBLED_NAME_DETAIL);
|
|
134
178
|
}
|
|
135
179
|
}
|
|
136
180
|
ts.forEachChild(node, visit);
|
|
@@ -139,6 +183,84 @@ function checkDynamicDispatch(files) {
|
|
|
139
183
|
}
|
|
140
184
|
return findings;
|
|
141
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Report every {@link RECEIVER_ANCHORED_MEMBERS} member a binding pattern pulls
|
|
188
|
+
* out of a known receiver.
|
|
189
|
+
*
|
|
190
|
+
* A computed property in the pattern — `const { [name]: fn } = ctx` — is
|
|
191
|
+
* `ctx[name]` written as a destructuring, and is recorded as that same finding
|
|
192
|
+
* so the two spellings aggregate together.
|
|
193
|
+
* @param pattern - the binding pattern.
|
|
194
|
+
* @param receiver - how the finding names the object being destructured.
|
|
195
|
+
* @param report - the reporter.
|
|
196
|
+
*/
|
|
197
|
+
function reportPatternMembers(pattern, receiver, report) {
|
|
198
|
+
for (const element of pattern.elements) {
|
|
199
|
+
const property = element.propertyName ?? element.name;
|
|
200
|
+
if (ts.isComputedPropertyName(property)) {
|
|
201
|
+
report(element, `resolves a member of ${receiver} from a computed name`, ASSEMBLED_NAME_DETAIL);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!ts.isIdentifier(property) || !RECEIVER_ANCHORED_MEMBERS.has(property.text))
|
|
205
|
+
continue;
|
|
206
|
+
report(element, `binds \`${property.text}\` off ${receiver} to a bare name`, DETACHED_MEMBER_DETAIL);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether an expression names a receiver whose members Tier B matches by name.
|
|
211
|
+
* @param node - the expression.
|
|
212
|
+
* @returns the receiver's own name, or `null`.
|
|
213
|
+
*/
|
|
214
|
+
function anchoredReceiver(node) {
|
|
215
|
+
if (node === undefined)
|
|
216
|
+
return null;
|
|
217
|
+
if (ts.isIdentifier(node) && ANCHORED_RECEIVERS.has(node.text))
|
|
218
|
+
return node.text;
|
|
219
|
+
// `this.ctx` and `self.ctx` are the same receiver held on a field.
|
|
220
|
+
if (ts.isPropertyAccessExpression(node) && DISPATCH_RECEIVERS.has(node.name.text))
|
|
221
|
+
return node.name.text;
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* C2 — a variable declaration that detaches an anchored member from its
|
|
226
|
+
* receiver, in either spelling: `const { provide } = ctx`, or
|
|
227
|
+
* `const provide = ctx.provide`.
|
|
228
|
+
* @param node - the variable declaration.
|
|
229
|
+
* @param report - the reporter.
|
|
230
|
+
*/
|
|
231
|
+
function reportDetachedBindings(node, report) {
|
|
232
|
+
const source = anchoredReceiver(node.initializer);
|
|
233
|
+
if (source !== null && ts.isObjectBindingPattern(node.name)) {
|
|
234
|
+
reportPatternMembers(node.name, `\`${source}\``, report);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const initializer = node.initializer;
|
|
238
|
+
if (initializer === undefined || !ts.isPropertyAccessExpression(initializer))
|
|
239
|
+
return;
|
|
240
|
+
const receiver = anchoredReceiver(initializer.expression);
|
|
241
|
+
if (receiver === null || !RECEIVER_ANCHORED_MEMBERS.has(initializer.name.text))
|
|
242
|
+
return;
|
|
243
|
+
report(node, `binds \`${initializer.name.text}\` off \`${receiver}\` to a bare name`, DETACHED_MEMBER_DETAIL);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* C2 — a plugin entry point that destructures its context parameter:
|
|
247
|
+
* `export function apply({ provide }) { … }`.
|
|
248
|
+
*
|
|
249
|
+
* The receiver is known here without any binding to follow: `apply`'s first
|
|
250
|
+
* parameter is the plugin context by the harness's own mount contract, which is
|
|
251
|
+
* what keeps this off every other function that destructures an options object.
|
|
252
|
+
* @param node - the function-like declaration.
|
|
253
|
+
* @param report - the reporter.
|
|
254
|
+
*/
|
|
255
|
+
function reportDetachedContextParameter(node, report) {
|
|
256
|
+
const name = node.name;
|
|
257
|
+
if (name === undefined || !ts.isIdentifier(name) || name.text !== PLUGIN_ENTRY)
|
|
258
|
+
return;
|
|
259
|
+
const first = node.parameters[0];
|
|
260
|
+
if (first === undefined || !ts.isObjectBindingPattern(first.name))
|
|
261
|
+
return;
|
|
262
|
+
reportPatternMembers(first.name, 'the plugin context', report);
|
|
263
|
+
}
|
|
142
264
|
/**
|
|
143
265
|
* Whether an expression names the plugin context.
|
|
144
266
|
*
|
|
@@ -174,16 +296,22 @@ function receiverName(node) {
|
|
|
174
296
|
/* v8 ignore stop */
|
|
175
297
|
}
|
|
176
298
|
/**
|
|
177
|
-
* Whether a node builds a string at runtime rather than naming one
|
|
178
|
-
*
|
|
179
|
-
*
|
|
299
|
+
* Whether a node builds a string at runtime rather than naming one, and does it
|
|
300
|
+
* in a way the constant folder could not follow.
|
|
301
|
+
*
|
|
302
|
+
* A plain identifier is deliberately excluded: `ctx.on(EVENT_NAME, …)` against a
|
|
303
|
+
* module constant is ordinary code, and treating it as evasion would degrade the
|
|
180
304
|
* analysis of nearly every well-written plugin.
|
|
181
305
|
* @param node - the argument node.
|
|
182
|
-
* @returns true for concatenation,
|
|
306
|
+
* @returns true for an unfoldable concatenation, template, or call.
|
|
183
307
|
*/
|
|
184
308
|
function isAssembledName(node) {
|
|
185
309
|
if (node === undefined)
|
|
186
310
|
return false;
|
|
311
|
+
// A name Tier B folded to a constant is a name Tier B matched. Reporting it
|
|
312
|
+
// here as well would degrade the whole report over a site that was read.
|
|
313
|
+
if (foldConstantString(node) !== null)
|
|
314
|
+
return false;
|
|
187
315
|
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
|
|
188
316
|
return true;
|
|
189
317
|
if (ts.isTemplateExpression(node))
|
package/lib/index.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* @module dsh-plugin-inspector
|
|
13
13
|
*/
|
|
14
14
|
export { analyze, exceedsThreshold, inspect, TOOL_NAME, TOOL_VERSION } from "./inspect.js";
|
|
15
|
+
export { MAX_ATTESTATION_BYTES, packageUrl, PROVENANCE_PREDICATE_TYPE, provenanceAbsent, provenanceUnavailable, provenanceUnreadable, readProvenance, } from "./attestation.js";
|
|
15
16
|
export { inspectFromNpm, precheck } from "./npm.js";
|
|
16
|
-
export { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
|
|
17
|
+
export { attestationUrl, DEFAULT_REGISTRY, fetchAttestation, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, verifyIntegrity, } from "./registry.js";
|
|
17
18
|
export { renderHuman, renderJson } from "./report.js";
|
|
18
19
|
export { classifyExpression, isJsExpr, parsePatchDocument, patchSchema, PatchParseError, } from "./cordis-yaml.js";
|
|
19
20
|
export { declaredPackages, ManifestError, parseManifest } from "./manifest.js";
|
package/lib/inspect.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @module dsh-plugin-inspector/inspect
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { provenanceUnavailable } from "./attestation.js";
|
|
13
14
|
import { EXPRESSION_CLASSES, PatchParseError, parsePatchDocument, } from "./cordis-yaml.js";
|
|
14
15
|
import { isCordisConfigFile, isModelVisibleText, isSourceFile, normalizePackagePath } from "./files.js";
|
|
15
16
|
import { HARNESS_REFERENCE } from "./knowledge.js";
|
|
@@ -95,11 +96,13 @@ export async function inspect(target) {
|
|
|
95
96
|
/**
|
|
96
97
|
* Run every check over an already-decoded package.
|
|
97
98
|
* @param source - the decoded package.
|
|
98
|
-
* @param registry -
|
|
99
|
+
* @param registry - where the bytes came from, when they were fetched from a registry.
|
|
100
|
+
* @param provenance - what the registry's attestation said and what was checked
|
|
101
|
+
* of it; defaults to the `unavailable` fact the two local modes carry.
|
|
99
102
|
* @returns the complete report.
|
|
100
103
|
* @throws ManifestError when the manifest cannot be read.
|
|
101
104
|
*/
|
|
102
|
-
export function analyze(source, registry) {
|
|
105
|
+
export function analyze(source, registry, provenance = provenanceUnavailable()) {
|
|
103
106
|
const manifest = parseManifest(source.files.get('package.json') ?? '');
|
|
104
107
|
const declared = manifest.dsh.bundle?.patch;
|
|
105
108
|
const mountsAsBundle = declared !== undefined;
|
|
@@ -131,6 +134,7 @@ export function analyze(source, registry) {
|
|
|
131
134
|
unmountedPatchFiles: others,
|
|
132
135
|
sourceFiles,
|
|
133
136
|
modelVisibleFiles,
|
|
137
|
+
provenance,
|
|
134
138
|
};
|
|
135
139
|
const tierC = runTierC(input);
|
|
136
140
|
const unreadable = tierC.filter(finding => !NON_DEGRADING_CHECKS.has(finding.checkId));
|
|
@@ -148,6 +152,7 @@ export function analyze(source, registry) {
|
|
|
148
152
|
packageName: manifest.name,
|
|
149
153
|
packageVersion: manifest.version,
|
|
150
154
|
license: manifest.license,
|
|
155
|
+
provenance,
|
|
151
156
|
mountsAsBundle,
|
|
152
157
|
bundlePatchPath: declared ?? null,
|
|
153
158
|
shipsClientBundle: manifest.dsh.client !== undefined && manifest.exportPaths.includes('./client'),
|