dsh-plugin-inspector 0.5.0 → 0.7.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 +12 -5
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +48 -3
- package/lib/checks/tier-b.js +128 -28
- package/lib/checks/tier-c.js +149 -19
- package/lib/index.js +2 -1
- package/lib/injection.js +7 -1
- package/lib/inspect.js +7 -2
- package/lib/knowledge.js +38 -9
- package/lib/npm.js +58 -5
- package/lib/registry.js +54 -0
- package/lib/report.js +61 -6
- package/lib/source.js +11 -2
- 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 +23 -3
- package/lib/types/model.d.ts +16 -4
- package/lib/types/npm.d.ts +12 -2
- package/lib/types/registry.d.ts +40 -0
- package/lib/types/source.d.ts +3 -1
- package/lib/types/syntax.d.ts +51 -0
- package/package.json +2 -1
package/lib/checks/tier-b.js
CHANGED
|
@@ -16,6 +16,7 @@ import ts from 'typescript';
|
|
|
16
16
|
import { lineColumn, snippet } from "../files.js";
|
|
17
17
|
import { scanInjection } from "../injection.js";
|
|
18
18
|
import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, } from "../knowledge.js";
|
|
19
|
+
import { foldConstantString, isBuiltinModuleGetter } from "../syntax.js";
|
|
19
20
|
/** Global functions that fetch over the network without any `ctx` service. */
|
|
20
21
|
const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
|
|
21
22
|
/** `process.env` keys whose names say they hold a secret. */
|
|
@@ -53,6 +54,13 @@ export function matchesCredentialPath(text) {
|
|
|
53
54
|
const DYNAMIC_CODE_CALLEES = new Set([
|
|
54
55
|
'eval', 'runInNewContext', 'runInThisContext', 'runInContext', 'compileFunction',
|
|
55
56
|
]);
|
|
57
|
+
/**
|
|
58
|
+
* The harness's own tool-definition helper, exported from
|
|
59
|
+
* `@deepseek-ai/dsh-tools`. Every registered tool in the harness is built by
|
|
60
|
+
* either calling it or handing `tools.register` a literal, so recognising the
|
|
61
|
+
* two shapes is what tells a tool `description` from every other kind.
|
|
62
|
+
*/
|
|
63
|
+
const TOOL_DEFINITION_HELPER = 'defineTool';
|
|
56
64
|
/** `ctx.systemPrompt` members that change what the model is told. */
|
|
57
65
|
const SYSTEM_PROMPT_MEMBERS = new Set([
|
|
58
66
|
'section', 'context', 'variable', 'tools', 'suppressRuntimeContext',
|
|
@@ -67,18 +75,18 @@ function tierB(finding) {
|
|
|
67
75
|
return { ...finding, tier: 'B', confidence: 'high', examples: [finding.evidence], occurrences: 1 };
|
|
68
76
|
}
|
|
69
77
|
/**
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
72
|
-
*
|
|
78
|
+
* The text a string argument holds, folding the constant forms — a `+` chain of
|
|
79
|
+
* literals, a template whose spans are literals, `[…].join(…)` over literals.
|
|
80
|
+
*
|
|
81
|
+
* Folding is bounded on purpose. An argument this cannot resolve is not a
|
|
82
|
+
* Tier B miss to paper over: it is a Tier C signal, and `tier-c.ts` records it
|
|
83
|
+
* by asking the same folder, so a site is either matched here or degraded
|
|
84
|
+
* there and never both.
|
|
73
85
|
* @param node - the argument expression.
|
|
74
|
-
* @returns the
|
|
86
|
+
* @returns the text, or `null`.
|
|
75
87
|
*/
|
|
76
88
|
function literalText(node) {
|
|
77
|
-
|
|
78
|
-
return null;
|
|
79
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
80
|
-
return node.text;
|
|
81
|
-
return null;
|
|
89
|
+
return foldConstantString(node);
|
|
82
90
|
}
|
|
83
91
|
/**
|
|
84
92
|
* Strip the `node:` prefix so `node:fs` and `fs` compare equal.
|
|
@@ -89,9 +97,13 @@ function bareModule(specifier) {
|
|
|
89
97
|
return specifier.startsWith('node:') ? specifier.slice(5) : specifier;
|
|
90
98
|
}
|
|
91
99
|
/**
|
|
92
|
-
* Every module
|
|
100
|
+
* Every module the file reaches by a name this tool can resolve.
|
|
101
|
+
*
|
|
102
|
+
* Three ways in, not two. `import` and `require` are the declarations a reader
|
|
103
|
+
* looks for; `process.getBuiltinModule('node:fs')` is a third that needs
|
|
104
|
+
* neither, returns the same module object, and appears in no import list.
|
|
93
105
|
* @param file - the parsed file.
|
|
94
|
-
* @returns
|
|
106
|
+
* @returns one entry per resolved reference.
|
|
95
107
|
*/
|
|
96
108
|
function moduleSpecifiers(file) {
|
|
97
109
|
const found = [];
|
|
@@ -100,15 +112,16 @@ function moduleSpecifiers(file) {
|
|
|
100
112
|
const text = literalText(node.moduleSpecifier);
|
|
101
113
|
/* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
|
|
102
114
|
if (text !== null)
|
|
103
|
-
found.push({ specifier: text, node });
|
|
115
|
+
found.push({ specifier: text, node, via: 'import' });
|
|
104
116
|
}
|
|
105
117
|
if (ts.isCallExpression(node)) {
|
|
106
118
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
107
119
|
const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
108
|
-
|
|
120
|
+
const isBuiltin = isBuiltinModuleGetter(node);
|
|
121
|
+
if (isRequire || isImport || isBuiltin) {
|
|
109
122
|
const text = literalText(node.arguments[0]);
|
|
110
123
|
if (text !== null)
|
|
111
|
-
found.push({ specifier: text, node });
|
|
124
|
+
found.push({ specifier: text, node, via: isBuiltin ? 'builtin-getter' : 'import' });
|
|
112
125
|
}
|
|
113
126
|
}
|
|
114
127
|
ts.forEachChild(node, visit);
|
|
@@ -129,9 +142,25 @@ function at(file, node) {
|
|
|
129
142
|
snippet: snippet(file.text.slice(node.getStart(file.node), node.end)),
|
|
130
143
|
};
|
|
131
144
|
}
|
|
132
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* How a finding names the way a module was reached.
|
|
147
|
+
*
|
|
148
|
+
* `Imports` would be false of `process.getBuiltinModule('node:fs')`, and the
|
|
149
|
+
* difference is the point of covering it: the module arrives with no import
|
|
150
|
+
* declaration and no `require` for a reader to find.
|
|
151
|
+
* @param reference - the resolved module reference.
|
|
152
|
+
* @returns the opening clause of the finding's title.
|
|
153
|
+
*/
|
|
154
|
+
function reachedBy(reference) {
|
|
155
|
+
return reference.via === 'import'
|
|
156
|
+
? `Imports \`${reference.specifier}\``
|
|
157
|
+
: `Loads \`${reference.specifier}\` through \`process.getBuiltinModule\``;
|
|
158
|
+
}
|
|
159
|
+
/** B9, B7, B13 — what modules the file reaches. */
|
|
133
160
|
function checkImports(file, accumulator) {
|
|
134
|
-
for (const
|
|
161
|
+
for (const reference of moduleSpecifiers(file)) {
|
|
162
|
+
const { specifier, node } = reference;
|
|
163
|
+
const reached = reachedBy(reference);
|
|
135
164
|
const bare = bareModule(specifier);
|
|
136
165
|
const unmediated = UNMEDIATED_PROCESS_MODULES.get(bare);
|
|
137
166
|
if (unmediated !== undefined) {
|
|
@@ -143,12 +172,13 @@ function checkImports(file, accumulator) {
|
|
|
143
172
|
// reads a credential or reaches the network. On its own it is a
|
|
144
173
|
// capability half the ecosystem has.
|
|
145
174
|
severity: 'medium',
|
|
146
|
-
title:
|
|
175
|
+
title: `${reached}, which ${unmediated}`,
|
|
147
176
|
detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
|
|
148
177
|
+ 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
|
|
149
178
|
+ 'bundle layer gets no such restriction, so this import does exactly what the harness forbids elsewhere.',
|
|
150
179
|
evidence: at(file, node),
|
|
151
|
-
bypass: 'a
|
|
180
|
+
bypass: 'a specifier this tool cannot fold to a constant — `import(name)` against a binding — which C2 '
|
|
181
|
+
+ 'reports, so the negative degrades rather than passing quietly',
|
|
152
182
|
}));
|
|
153
183
|
}
|
|
154
184
|
if (NETWORK_MODULES.has(bare)) {
|
|
@@ -157,11 +187,11 @@ function checkImports(file, accumulator) {
|
|
|
157
187
|
name: 'network-egress',
|
|
158
188
|
subject: specifier,
|
|
159
189
|
severity: 'medium',
|
|
160
|
-
title:
|
|
190
|
+
title: `${reached}, which can move bytes off the machine`,
|
|
161
191
|
detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
|
|
162
192
|
+ 'declared reason. It is recorded because paired with a credential read it becomes B8.',
|
|
163
193
|
evidence: at(file, node),
|
|
164
|
-
bypass: 'a
|
|
194
|
+
bypass: 'a transitive dependency doing the request on this package\'s behalf',
|
|
165
195
|
});
|
|
166
196
|
accumulator.findings.push(finding);
|
|
167
197
|
accumulator.networkCall ??= finding;
|
|
@@ -172,12 +202,12 @@ function checkImports(file, accumulator) {
|
|
|
172
202
|
name: 'unmediated-filesystem',
|
|
173
203
|
subject: specifier,
|
|
174
204
|
severity: 'medium',
|
|
175
|
-
title:
|
|
205
|
+
title: `${reached} rather than using the \`ctx.fs\` service`,
|
|
176
206
|
detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
|
|
177
207
|
+ '`fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and '
|
|
178
208
|
+ 'nothing appears in the session log.',
|
|
179
209
|
evidence: at(file, node),
|
|
180
|
-
bypass: 'a
|
|
210
|
+
bypass: 'a transitive dependency reading the file on this package\'s behalf',
|
|
181
211
|
}));
|
|
182
212
|
}
|
|
183
213
|
}
|
|
@@ -203,7 +233,8 @@ function checkSeamReplacement(file, node, accumulator) {
|
|
|
203
233
|
+ 'package\'s implementation for every consumer in the scope, and consumers cannot tell the difference.'
|
|
204
234
|
+ (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
|
|
205
235
|
evidence: at(file, node),
|
|
206
|
-
bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched"
|
|
236
|
+
bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched — and neither is a "
|
|
237
|
+
+ '`provide` destructured off `ctx` and called through the bare name. C2 reports both',
|
|
207
238
|
}));
|
|
208
239
|
}
|
|
209
240
|
/** B5 — changing what the model is told. */
|
|
@@ -345,11 +376,75 @@ function checkCredentialRead(file, node, accumulator) {
|
|
|
345
376
|
detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
|
|
346
377
|
+ 'must do it. It is recorded because paired with network access it becomes B8.',
|
|
347
378
|
evidence: at(file, node),
|
|
348
|
-
bypass: 'a
|
|
379
|
+
bypass: 'a key this tool cannot fold to a constant, or reading the whole `process.env` object and indexing it later',
|
|
349
380
|
});
|
|
350
381
|
accumulator.findings.push(finding);
|
|
351
382
|
accumulator.credentialRead ??= finding;
|
|
352
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Whether a call hands its arguments to the tool registry: the registry call
|
|
386
|
+
* itself, `<ctx>.tools.register(…)`, or the harness's `defineTool(…)` helper,
|
|
387
|
+
* whose argument is a tool definition and nothing else.
|
|
388
|
+
* @param node - the call expression.
|
|
389
|
+
* @returns true when its arguments are tool definitions.
|
|
390
|
+
*/
|
|
391
|
+
function isToolRegistration(node) {
|
|
392
|
+
const callee = node.expression;
|
|
393
|
+
if (ts.isIdentifier(callee))
|
|
394
|
+
return callee.text === TOOL_DEFINITION_HELPER;
|
|
395
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === 'register'
|
|
396
|
+
&& ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'tools';
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Whether a name bound in this file is passed to a tool registration call, so
|
|
400
|
+
* a definition built as `const tool = {…}` and registered on a later line is
|
|
401
|
+
* still recognised as one.
|
|
402
|
+
* @param name - the bound identifier.
|
|
403
|
+
* @param file - the parsed file it was bound in.
|
|
404
|
+
* @returns true when a registration call in the same file receives it.
|
|
405
|
+
*/
|
|
406
|
+
function isRegisteredName(name, file) {
|
|
407
|
+
let registered = false;
|
|
408
|
+
const visit = (node) => {
|
|
409
|
+
if (ts.isCallExpression(node) && isToolRegistration(node)
|
|
410
|
+
&& node.arguments.some(argument => ts.isIdentifier(argument) && argument.text === name)) {
|
|
411
|
+
registered = true;
|
|
412
|
+
}
|
|
413
|
+
ts.forEachChild(node, visit);
|
|
414
|
+
};
|
|
415
|
+
ts.forEachChild(file.node, visit);
|
|
416
|
+
return registered;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Whether a `description` property belongs to a tool definition this package
|
|
420
|
+
* registers.
|
|
421
|
+
*
|
|
422
|
+
* The receiver guard is the whole check. `description` is one of the commonest
|
|
423
|
+
* property names in JavaScript — a JSON schema, an OpenAPI document, a
|
|
424
|
+
* changelog entry and a CLI option table all carry one — and none of that text
|
|
425
|
+
* reaches a model. Without the guard the injection heuristics run on release
|
|
426
|
+
* notes, and the finding's title then asserts something about a tool that the
|
|
427
|
+
* package does not have.
|
|
428
|
+
*
|
|
429
|
+
* Nested properties count, because the whole definition is model-visible: a
|
|
430
|
+
* parameter's `description` is rendered into the tool schema the model
|
|
431
|
+
* receives alongside the tool's own.
|
|
432
|
+
* @param node - the `description` property assignment.
|
|
433
|
+
* @param file - the parsed file it came from.
|
|
434
|
+
* @returns true when an enclosing object literal is a registered tool definition.
|
|
435
|
+
*/
|
|
436
|
+
function isRegisteredToolDescription(node, file) {
|
|
437
|
+
let parent = node.parent;
|
|
438
|
+
for (;;) {
|
|
439
|
+
if (ts.isCallExpression(parent))
|
|
440
|
+
return isToolRegistration(parent);
|
|
441
|
+
if (ts.isVariableDeclaration(parent))
|
|
442
|
+
return ts.isIdentifier(parent.name) && isRegisteredName(parent.name.text, file);
|
|
443
|
+
if (!ts.isObjectLiteralExpression(parent) && !ts.isPropertyAssignment(parent))
|
|
444
|
+
return false;
|
|
445
|
+
parent = parent.parent;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
353
448
|
/** B10 — injection phrasing in a registered tool description. */
|
|
354
449
|
function checkToolDescription(file, node, accumulator) {
|
|
355
450
|
if (!ts.isPropertyAssignment(node))
|
|
@@ -359,6 +454,8 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
359
454
|
const text = literalText(node.initializer);
|
|
360
455
|
if (text === null)
|
|
361
456
|
return;
|
|
457
|
+
if (!isRegisteredToolDescription(node, file))
|
|
458
|
+
return;
|
|
362
459
|
for (const match of scanInjection(text)) {
|
|
363
460
|
accumulator.findings.push(tierB({
|
|
364
461
|
checkId: 'B10',
|
|
@@ -366,11 +463,14 @@ function checkToolDescription(file, node, accumulator) {
|
|
|
366
463
|
subject: match.ruleId,
|
|
367
464
|
severity: 'high',
|
|
368
465
|
title: `Tool description ${match.meaning}`,
|
|
369
|
-
detail: `Heuristic \`${match.ruleId}\` matched a
|
|
370
|
-
+ 'verbatim on every request that lists the tool. This is a natural-language
|
|
371
|
-
+ 'rephrasing, and it can fire on a description that legitimately discusses the
|
|
466
|
+
detail: `Heuristic \`${match.ruleId}\` matched a \`description\` inside a registered tool definition, which is `
|
|
467
|
+
+ 'prompt text the model receives verbatim on every request that lists the tool. This is a natural-language '
|
|
468
|
+
+ 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
|
|
469
|
+
+ 'subject.',
|
|
372
470
|
evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
|
|
373
|
-
bypass: 'any rephrasing the pattern does not cover,
|
|
471
|
+
bypass: 'any rephrasing the pattern does not cover, assembling the description out of anything this tool '
|
|
472
|
+
+ 'cannot fold to a constant, or registering the definition through a value this tool does not track — a '
|
|
473
|
+
+ 'definition exported from one file and passed to `tools.register` in another is not matched',
|
|
374
474
|
}));
|
|
375
475
|
}
|
|
376
476
|
}
|
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))
|
|
@@ -330,7 +458,9 @@ function checkUnreadableFiles(input) {
|
|
|
330
458
|
detail: reason === 'binary'
|
|
331
459
|
? 'Binary payloads — native addons, WebAssembly, archives — are shipped code this tool cannot read at all. '
|
|
332
460
|
+ 'A mounted layer can load a `.node` addon with no restriction whatsoever.'
|
|
333
|
-
: 'These files
|
|
461
|
+
: 'These files were not read: each either passed a size or count cap, or is not a regular file the reader '
|
|
462
|
+
+ 'can open — a symbolic link, a FIFO, a socket, or a directory it was refused. The subject names which. '
|
|
463
|
+
+ 'Nothing is claimed about their contents.',
|
|
334
464
|
/* v8 ignore next -- a reason only appears in the map once a path was pushed under it. */
|
|
335
465
|
evidence: { file: paths[0] ?? '', snippet: snippet(paths.slice(0, 8).join(', ')) },
|
|
336
466
|
bypass: 'none — this finding is about the analysis, not about the plugin',
|
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/injection.js
CHANGED
|
@@ -39,7 +39,13 @@ export const INJECTION_RULES = [
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
id: 'credential-exfiltration',
|
|
42
|
-
|
|
42
|
+
// The dotted filenames carry their own boundary. A `\b` in front of the
|
|
43
|
+
// whole alternation cannot match at the start of `.env` or `.npmrc`: the
|
|
44
|
+
// preceding character is a space and the next is a `.`, so neither side of
|
|
45
|
+
// that position is a word character and the boundary does not exist there.
|
|
46
|
+
// Under a shared `\b` those two alternatives match nothing, while the
|
|
47
|
+
// word-initial ones beside them keep working and hide it.
|
|
48
|
+
pattern: /\b(?:send|post|upload|transmit|exfiltrate|forward|report)\b[^.\n]{0,60}(?:\b(?:api[_ -]?key|access[_ -]?token|secret|credential|password|id_rsa)\b|\.(?:env|npmrc)\b)/i,
|
|
43
49
|
meaning: 'instructs the model to move a credential somewhere',
|
|
44
50
|
},
|
|
45
51
|
{
|
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'),
|