dsh-plugin-inspector 0.6.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 +10 -8
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +41 -0
- package/lib/checks/tier-b.js +51 -27
- package/lib/checks/tier-c.js +146 -18
- package/lib/index.js +2 -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 +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 +23 -3
- 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'),
|
package/lib/knowledge.js
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* Harness version these tables were transcribed from — the version string in
|
|
14
|
-
* the
|
|
14
|
+
* the shipped bundles' own `package.json`, which is `dsh`'s own version.
|
|
15
|
+
*
|
|
16
|
+
* Re-verified against `0.1.1-rc.2`, the release npm tags `latest`, by
|
|
17
|
+
* extracting each table from the published packages and diffing it against the
|
|
18
|
+
* one here. What moved: six rows inserted by the web bundle, three seam keys,
|
|
19
|
+
* and two sandbox traps this table had never carried. What did not: every row
|
|
20
|
+
* name, every row's bundle membership, and the waterfall event set.
|
|
15
21
|
*/
|
|
16
|
-
export const HARNESS_REFERENCE = '0.1.
|
|
22
|
+
export const HARNESS_REFERENCE = '0.1.1-rc.2';
|
|
17
23
|
/**
|
|
18
24
|
* The three profile bundles the harness ships, mapped to what each one is.
|
|
19
25
|
* A package that *is* one of these composes the core rows rather than modifying
|
|
@@ -63,6 +69,7 @@ export const CORE_ROWS = new Map([
|
|
|
63
69
|
['cordis-host-runner', { module: '@deepseek-ai/dsh-cordis-host-runner', bundles: ['web-app'] }],
|
|
64
70
|
['credentials', { module: '@deepseek-ai/dsh-credentials-local', bundles: ['base'] }],
|
|
65
71
|
['directory-picker', { module: '@deepseek-ai/dsh-host-directory-picker-auto', bundles: ['web-app'] }],
|
|
72
|
+
['file-reference-local', { module: '@deepseek-ai/dsh-file-reference-local', bundles: ['web-app'] }],
|
|
66
73
|
['fs-observation-policy', { module: '@deepseek-ai/dsh-fs-observation-policy', bundles: ['base'] }],
|
|
67
74
|
['fs-sandbox', { module: '@deepseek-ai/dsh-fs-sandbox', bundles: ['base'] }],
|
|
68
75
|
['goal', { module: '@deepseek-ai/dsh-goal', bundles: ['base'] }],
|
|
@@ -92,6 +99,7 @@ export const CORE_ROWS = new Map([
|
|
|
92
99
|
['session-projection', { module: '@deepseek-ai/dsh-session-projection', bundles: ['base'] }],
|
|
93
100
|
['session-projection-cache', { module: '@deepseek-ai/dsh-session-projection-cache', bundles: ['web-app'] }],
|
|
94
101
|
['session-query-sqlite', { module: '@deepseek-ai/dsh-session-query-sqlite', bundles: ['base'] }],
|
|
102
|
+
['session-reference', { module: '@deepseek-ai/dsh-session-reference', bundles: ['web-app'] }],
|
|
95
103
|
['session-stats', { module: '@deepseek-ai/dsh-session-stats', bundles: ['web-app'] }],
|
|
96
104
|
['session-telemetry-otel', { module: '@deepseek-ai/dsh-session-telemetry-otel', bundles: ['base'] }],
|
|
97
105
|
['session-title', { module: '@deepseek-ai/dsh-session-title', bundles: ['base'] }],
|
|
@@ -137,6 +145,8 @@ export const CORE_ROWS = new Map([
|
|
|
137
145
|
['typert-gateway', { module: '@deepseek-ai/dsh-api-gateway', bundles: ['base'] }],
|
|
138
146
|
['typert-loader', { module: '@deepseek-ai/dsh-typert-loader', bundles: ['base'] }],
|
|
139
147
|
['ui-agent-preset', { module: '@deepseek-ai/dsh-client-ui-agent-preset', bundles: ['web-app'] }],
|
|
148
|
+
['ui-attachment', { module: '@deepseek-ai/dsh-client-ui-attachment', bundles: ['web-app'] }],
|
|
149
|
+
['ui-brand-official', { module: '@deepseek-ai/dsh-client-ui-brand-official', bundles: ['web-app'] }],
|
|
140
150
|
['ui-commands', { module: '@deepseek-ai/dsh-client-ui-commands', bundles: ['web-app'] }],
|
|
141
151
|
['ui-conversation', { module: '@deepseek-ai/dsh-client-ui-conversation', bundles: ['web-app'] }],
|
|
142
152
|
['ui-cordis', { module: '@deepseek-ai/dsh-client-ui-cordis', bundles: ['web-app'] }],
|
|
@@ -149,6 +159,8 @@ export const CORE_ROWS = new Map([
|
|
|
149
159
|
['ui-model-selection', { module: '@deepseek-ai/dsh-client-ui-model-selection', bundles: ['web-app'] }],
|
|
150
160
|
['ui-permission', { module: '@deepseek-ai/dsh-client-ui-permission-presets', bundles: ['web-app'] }],
|
|
151
161
|
['ui-plan', { module: '@deepseek-ai/dsh-client-ui-plan', bundles: ['web-app'] }],
|
|
162
|
+
['ui-reference', { module: '@deepseek-ai/dsh-client-ui-reference', bundles: ['web-app'] }],
|
|
163
|
+
['ui-renderer', { module: '@deepseek-ai/dsh-client-ui-renderer', bundles: ['web-app'] }],
|
|
152
164
|
['ui-settings', { module: '@deepseek-ai/dsh-client-ui-settings', bundles: ['web-app'] }],
|
|
153
165
|
['ui-settings-general', { module: '@deepseek-ai/dsh-client-ui-settings-general', bundles: ['web-app'] }],
|
|
154
166
|
['ui-settings-models', { module: '@deepseek-ai/dsh-client-ui-settings-models', bundles: ['web-app'] }],
|
|
@@ -207,10 +219,10 @@ export const SECURITY_ROW_IDS = new Map([
|
|
|
207
219
|
* replaces a core service for every consumer in its scope.
|
|
208
220
|
*/
|
|
209
221
|
export const SEAM_KEYS = new Set([
|
|
210
|
-
'agentDefaultModel', 'agentLoop', 'agentPresets', 'agents', 'apiProxy', 'approval',
|
|
211
|
-
'attachments', 'clientModules', 'codeRuntime', 'commands', 'compaction',
|
|
212
|
-
'
|
|
213
|
-
'messageFeedback', 'permissionPresets', 'planMode', 'sandbox', 'sandboxPolicy',
|
|
222
|
+
'agentDefaultModel', 'agentLoop', 'agentPresets', 'agents', 'agentTeams', 'apiProxy', 'approval',
|
|
223
|
+
'attachments', 'authorization', 'clientModules', 'codeRuntime', 'commands', 'compaction',
|
|
224
|
+
'credentials', 'directoryPicker', 'e2b', 'fileReferences', 'fs', 'goals', 'invariants', 'jobs',
|
|
225
|
+
'llm', 'lsp', 'messageFeedback', 'permissionPresets', 'planMode', 'sandbox', 'sandboxPolicy',
|
|
214
226
|
'sessionPersistence', 'sessionProjectionCache', 'sessionProjections', 'sessionQuery',
|
|
215
227
|
'sessionReferenceResolver', 'sessions', 'sessionTelemetry', 'sessionTitle', 'settings',
|
|
216
228
|
'shell', 'shellEnv', 'skills', 'spillStore', 'storage', 'storageDomain', 'subagents',
|
|
@@ -218,10 +230,21 @@ export const SEAM_KEYS = new Set([
|
|
|
218
230
|
'tools', 'typert', 'typertGateway', 'userQuestions', 'web', 'webServer', 'workflowEngine',
|
|
219
231
|
'workspaceRegistry',
|
|
220
232
|
]);
|
|
221
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* The subset of {@link SEAM_KEYS} whose replacement removes a constraint.
|
|
235
|
+
*
|
|
236
|
+
* `authorization` joined the catalogue in `0.1.1-rc.2`: it is the registry of
|
|
237
|
+
* flows that obtain a credential through a conversation with the user, so
|
|
238
|
+
* providing it means owning that conversation. That is the same class of
|
|
239
|
+
* substitution as `credentials`, which this set already holds. The other two
|
|
240
|
+
* keys the release added are not: `fileReferences` decides which paths are
|
|
241
|
+
* offered for completion and `agentTeams` is the team form of `subagents`,
|
|
242
|
+
* which is deliberately not here either.
|
|
243
|
+
*/
|
|
222
244
|
export const SECURITY_SEAM_KEYS = new Set([
|
|
223
|
-
'approval', 'sandbox', 'sandboxPolicy', 'permissionPresets', 'credentials',
|
|
224
|
-
'shell', 'fs', 'tools', 'agentLoop', 'sessionPersistence', 'sessionTelemetry',
|
|
245
|
+
'approval', 'authorization', 'sandbox', 'sandboxPolicy', 'permissionPresets', 'credentials',
|
|
246
|
+
'subprocess', 'shell', 'fs', 'tools', 'agentLoop', 'sessionPersistence', 'sessionTelemetry',
|
|
247
|
+
'invariants',
|
|
225
248
|
]);
|
|
226
249
|
/**
|
|
227
250
|
* Waterfall events, from `EVENT_API` in the api-catalog. A listener on one of
|
|
@@ -229,6 +252,10 @@ export const SECURITY_SEAM_KEYS = new Set([
|
|
|
229
252
|
* without calling it short-circuits the chain including the built-in behavior.
|
|
230
253
|
*
|
|
231
254
|
* Note there is no `fs/read-intent` — the intent family is write and edit only.
|
|
255
|
+
*
|
|
256
|
+
* Unchanged in `0.1.1-rc.2`. The catalogue's event set grew by four and lost
|
|
257
|
+
* one, but every addition carries `mode: 'emit'`, and only `mode: 'waterfall'`
|
|
258
|
+
* hands a listener the trailing `next` this set is about.
|
|
232
259
|
*/
|
|
233
260
|
export const WATERFALL_EVENTS = new Set([
|
|
234
261
|
'agent/pre-step', 'agent/request', 'agent/request-error', 'approval/request',
|
|
@@ -252,6 +279,8 @@ export const SANDBOX_DENIED_GLOBALS = new Map([
|
|
|
252
279
|
['setTimeout', "redirected to the cordis timer service (inject: ['timer'])"],
|
|
253
280
|
['setInterval', "redirected to the cordis timer service (inject: ['timer'])"],
|
|
254
281
|
['setImmediate', "redirected to the cordis timer service (inject: ['timer'])"],
|
|
282
|
+
['clearTimeout', "redirected to the cordis timer service (inject: ['timer'])"],
|
|
283
|
+
['clearInterval', "redirected to the cordis timer service (inject: ['timer'])"],
|
|
255
284
|
]);
|
|
256
285
|
/**
|
|
257
286
|
* Node builtins that start or evaluate code off the mediated path. A mounted
|
package/lib/npm.js
CHANGED
|
@@ -7,16 +7,27 @@
|
|
|
7
7
|
* fixed and their order is the guarantee:
|
|
8
8
|
*
|
|
9
9
|
* 1. read the version document (~3 KB) — which already answers
|
|
10
|
-
* `hasInstallScript`, the install lifecycle scripts,
|
|
10
|
+
* `hasInstallScript`, the install lifecycle scripts, `dsh.bundle`, and
|
|
11
|
+
* whether the registry holds a provenance attestation at all;
|
|
11
12
|
* 2. download the tarball into memory;
|
|
12
13
|
* 3. verify `dist.integrity` **before** anything parses a byte of it;
|
|
13
|
-
* 4.
|
|
14
|
+
* 4. read the provenance attestation, when step 1 said there is one, and check
|
|
15
|
+
* it against the bytes step 3 vouched for;
|
|
16
|
+
* 5. decode in memory and analyse, exactly as the tarball path does.
|
|
17
|
+
*
|
|
18
|
+
* Step 4 is the only request this module makes that is not unconditional, and
|
|
19
|
+
* it is skipped for every package the version document says has no attestation
|
|
20
|
+
* — which on the measured corpus is 28 packages in 40. It never fails an
|
|
21
|
+
* analysis: an endpoint that is down or a bundle that does not decode leaves
|
|
22
|
+
* the provenance fact in state `unreadable`, which is a different answer from
|
|
23
|
+
* `absent` and is printed as one.
|
|
14
24
|
*
|
|
15
25
|
* No subprocess, no disk write, no lifecycle script, and no `npm pack`.
|
|
16
26
|
* @module dsh-plugin-inspector/npm
|
|
17
27
|
*/
|
|
28
|
+
import { provenanceAbsent, provenanceUnreadable, readProvenance, } from "./attestation.js";
|
|
18
29
|
import { analyze } from "./inspect.js";
|
|
19
|
-
import { DEFAULT_REGISTRY, fetchVerifiedTarball, parseSpec, resolvePackage, } from "./registry.js";
|
|
30
|
+
import { attestationUrl, DEFAULT_REGISTRY, fetchAttestation, fetchVerifiedTarball, parseSpec, RegistryError, resolvePackage, } from "./registry.js";
|
|
20
31
|
import { loadTarballBuffer } from "./source.js";
|
|
21
32
|
/**
|
|
22
33
|
* The metadata pre-check, which needs no tarball.
|
|
@@ -31,6 +42,46 @@ import { loadTarballBuffer } from "./source.js";
|
|
|
31
42
|
export async function precheck(spec, options = {}) {
|
|
32
43
|
return resolvePackage(parseSpec(spec), options);
|
|
33
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the registry's provenance attestation for a resolved package, when it
|
|
47
|
+
* has one, and check it against the bytes that were downloaded.
|
|
48
|
+
*
|
|
49
|
+
* Every failure on this path becomes a fact rather than a refusal. The tarball
|
|
50
|
+
* has already been checked against `dist.integrity`, so the analysis is sound
|
|
51
|
+
* whatever the attestation endpoint does, and turning a registry outage into
|
|
52
|
+
* exit code 2 would make provenance a precondition for reading a package
|
|
53
|
+
* instead of something reported about it.
|
|
54
|
+
* @param resolved - the packument reading for the version.
|
|
55
|
+
* @param registry - the registry base URL, without a trailing slash.
|
|
56
|
+
* @param tarball - the verified tarball bytes.
|
|
57
|
+
* @param options - where to fetch from.
|
|
58
|
+
* @returns what the registry says about the build origin, and what was checked.
|
|
59
|
+
*/
|
|
60
|
+
async function readRegistryProvenance(resolved, registry, tarball, options) {
|
|
61
|
+
if (resolved.provenancePredicateType === null)
|
|
62
|
+
return provenanceAbsent();
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = attestationUrl(registry, resolved.name, resolved.version);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
/* v8 ignore next -- `attestationUrl` refuses a name or version only with a RegistryError. */
|
|
69
|
+
if (!(error instanceof RegistryError))
|
|
70
|
+
throw error;
|
|
71
|
+
return provenanceUnreadable(null, error.message);
|
|
72
|
+
}
|
|
73
|
+
let body;
|
|
74
|
+
try {
|
|
75
|
+
body = await fetchAttestation(url, options);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
/* v8 ignore next -- `fetchAttestation` reports every refusal as a RegistryError. */
|
|
79
|
+
if (!(error instanceof RegistryError))
|
|
80
|
+
throw error;
|
|
81
|
+
return provenanceUnreadable(url, error.message);
|
|
82
|
+
}
|
|
83
|
+
return readProvenance(body, { name: resolved.name, version: resolved.version, tarball, url });
|
|
84
|
+
}
|
|
34
85
|
/**
|
|
35
86
|
* Fetch a published package and inspect it in memory.
|
|
36
87
|
* @param spec - `<name>` or `<name>@<version>`; no version means the `latest` tag.
|
|
@@ -42,9 +93,11 @@ export async function precheck(spec, options = {}) {
|
|
|
42
93
|
export async function inspectFromNpm(spec, options = {}) {
|
|
43
94
|
const resolved = await precheck(spec, options);
|
|
44
95
|
const verified = await fetchVerifiedTarball(resolved, options);
|
|
96
|
+
const registry = (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, '');
|
|
97
|
+
const attestation = await readRegistryProvenance(resolved, registry, verified.bytes, options);
|
|
45
98
|
const provenance = {
|
|
46
99
|
spec,
|
|
47
|
-
registry
|
|
100
|
+
registry,
|
|
48
101
|
resolvedVersion: resolved.version,
|
|
49
102
|
tarball: resolved.tarball,
|
|
50
103
|
digest: verified.digest,
|
|
@@ -54,5 +107,5 @@ export async function inspectFromNpm(spec, options = {}) {
|
|
|
54
107
|
tarballBytes: verified.bytes.byteLength,
|
|
55
108
|
};
|
|
56
109
|
const source = await loadTarballBuffer(verified.bytes, `npm:${resolved.name}@${resolved.version}`);
|
|
57
|
-
return analyze(source, provenance);
|
|
110
|
+
return analyze(source, provenance, attestation);
|
|
58
111
|
}
|
package/lib/registry.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* @module dsh-plugin-inspector/registry
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
|
+
import { MAX_ATTESTATION_BYTES } from "./attestation.js";
|
|
22
23
|
import { INSTALL_LIFECYCLE_SCRIPTS } from "./knowledge.js";
|
|
23
24
|
import { MAX_TOTAL_BYTES } from "./source.js";
|
|
24
25
|
/** The public npm registry, used when no other is named. */
|
|
@@ -146,6 +147,7 @@ export async function resolvePackage(spec, options = {}) {
|
|
|
146
147
|
integrity: typeof dist.integrity === 'string' ? dist.integrity : null,
|
|
147
148
|
shasum: typeof dist.shasum === 'string' ? dist.shasum : null,
|
|
148
149
|
hasInstallScript: record.hasInstallScript === true,
|
|
150
|
+
provenancePredicateType: asString(asRecord(asRecord(dist.attestations).provenance), 'predicateType'),
|
|
149
151
|
lifecycleScripts: INSTALL_LIFECYCLE_SCRIPTS.filter(name => typeof scripts[name] === 'string'),
|
|
150
152
|
bundlePatch: typeof bundle.patch === 'string' ? bundle.patch : null,
|
|
151
153
|
metadataBytes: body.byteLength,
|
|
@@ -162,6 +164,16 @@ function asRecord(value) {
|
|
|
162
164
|
? value
|
|
163
165
|
: {};
|
|
164
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Read a string field, or `null` when it is absent or of another type.
|
|
169
|
+
* @param record - the containing record.
|
|
170
|
+
* @param key - the field name.
|
|
171
|
+
* @returns the string, or `null`.
|
|
172
|
+
*/
|
|
173
|
+
function asString(record, key) {
|
|
174
|
+
const value = record[key];
|
|
175
|
+
return typeof value === 'string' ? value : null;
|
|
176
|
+
}
|
|
165
177
|
/**
|
|
166
178
|
* Refuse a tarball URL that points somewhere other than the registry that
|
|
167
179
|
* described it.
|
|
@@ -260,3 +272,45 @@ export async function fetchVerifiedTarball(resolved, options = {}) {
|
|
|
260
272
|
const bytes = await readCapped(response, MAX_TARBALL_BYTES, `tarball ${resolved.tarball}`);
|
|
261
273
|
return verifyIntegrity(bytes, resolved);
|
|
262
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* The endpoint an npm-compatible registry serves a version's attestation
|
|
277
|
+
* bundle from.
|
|
278
|
+
*
|
|
279
|
+
* Built from the registry base URL rather than read out of
|
|
280
|
+
* `dist.attestations.url`, which is the opposite of how the tarball URL is
|
|
281
|
+
* handled and is deliberate: the tarball has to come from wherever the registry
|
|
282
|
+
* says because there is no other way to name it, so that URL is taken from the
|
|
283
|
+
* document and then refused unless it is same-origin. An attestation needs no
|
|
284
|
+
* such freedom. Constructing the path here means a doctored packument cannot
|
|
285
|
+
* redirect the request at all, not even to another path on the same host.
|
|
286
|
+
*
|
|
287
|
+
* The name and version are re-validated because both come out of the version
|
|
288
|
+
* document, which is registry-controlled: `name` is not necessarily the name
|
|
289
|
+
* that was asked for, and it is interpolated into a URL.
|
|
290
|
+
* @param registry - the registry base URL, without a trailing slash.
|
|
291
|
+
* @param name - the resolved package name.
|
|
292
|
+
* @param version - the resolved version.
|
|
293
|
+
* @returns the absolute URL.
|
|
294
|
+
* @throws RegistryError when the document's name or version would not address this endpoint.
|
|
295
|
+
*/
|
|
296
|
+
export function attestationUrl(registry, name, version) {
|
|
297
|
+
if (!PACKAGE_NAME.test(name))
|
|
298
|
+
throw new RegistryError(`the version document names no npm package: ${name}`);
|
|
299
|
+
if (!VERSION_OR_TAG.test(version))
|
|
300
|
+
throw new RegistryError(`the version document names no version: ${version}`);
|
|
301
|
+
return `${registry}/-/npm/v1/attestations/${name}@${version}`;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Download an attestation document.
|
|
305
|
+
*
|
|
306
|
+
* Only ever called when the version document said there is one, so a package
|
|
307
|
+
* without provenance costs no request at all.
|
|
308
|
+
* @param url - the endpoint, from {@link attestationUrl}.
|
|
309
|
+
* @param options - where to fetch from.
|
|
310
|
+
* @returns the document as served.
|
|
311
|
+
* @throws RegistryError on a transport failure, a non-2xx status, or an oversized body.
|
|
312
|
+
*/
|
|
313
|
+
export async function fetchAttestation(url, options = {}) {
|
|
314
|
+
const response = await get(url, 'application/json', options);
|
|
315
|
+
return readCapped(response, MAX_ATTESTATION_BYTES, 'attestation document');
|
|
316
|
+
}
|