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/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
|
+
}
|
package/lib/report.js
CHANGED
|
@@ -19,6 +19,16 @@ const COLOR = {
|
|
|
19
19
|
bold: '\u001b[1m',
|
|
20
20
|
reset: '\u001b[0m',
|
|
21
21
|
};
|
|
22
|
+
/** Indent that lines a wrapped fact value up under the first one. */
|
|
23
|
+
const CONTINUATION = `\n${' '.repeat(18)}`;
|
|
24
|
+
/** What each unchecked claim means, spelled out rather than left as a key. */
|
|
25
|
+
const GAP_MEANING = {
|
|
26
|
+
'certificate-chain': 'that the signing certificate is Sigstore\'s — this tool carries no trust root, so a '
|
|
27
|
+
+ 'registry serving a doctored bundle passes every check above',
|
|
28
|
+
'transparency-log': 'the Rekor transparency-log inclusion proof in the bundle, which needs the same trust root',
|
|
29
|
+
'builder-identity': 'the signer identity, because the statement names no GitHub Actions workflow to compare the '
|
|
30
|
+
+ 'certificate against',
|
|
31
|
+
};
|
|
22
32
|
/** How the human report labels each severity. */
|
|
23
33
|
const LABEL = {
|
|
24
34
|
critical: 'CRITICAL',
|
|
@@ -123,6 +133,46 @@ function describeFileSet(facts) {
|
|
|
123
133
|
return `working tree narrowed to what npm would publish, by ${basis}`
|
|
124
134
|
+ ` (${facts.unpublishedFiles} unpublished file(s) not read)`;
|
|
125
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Say what the registry attested about this tarball's build origin, and — with
|
|
138
|
+
* equal prominence — what of that was checked here and what was not.
|
|
139
|
+
*
|
|
140
|
+
* The report is not allowed to let a reader mistake one for the other. "The
|
|
141
|
+
* registry told me a claim exists" and "I checked the claim" are different
|
|
142
|
+
* statements, and the second is only partly true: the digest, the package
|
|
143
|
+
* name, the DSSE signature and the certificate's own identity are checked, and
|
|
144
|
+
* whether the certificate belongs to Sigstore is not. So the `not checked` row
|
|
145
|
+
* is printed whenever the `checked` row is, never as a footnote and never
|
|
146
|
+
* conditionally.
|
|
147
|
+
* @param provenance - the fact.
|
|
148
|
+
* @returns the rows to print.
|
|
149
|
+
*/
|
|
150
|
+
function renderProvenance(provenance) {
|
|
151
|
+
if (provenance.state === 'unavailable') {
|
|
152
|
+
return [['provenance', `not readable here — ${provenance.reason}`]];
|
|
153
|
+
}
|
|
154
|
+
if (provenance.state === 'absent') {
|
|
155
|
+
return [['provenance',
|
|
156
|
+
'none — the registry published no build provenance for this version, which most published packages do not']];
|
|
157
|
+
}
|
|
158
|
+
if (provenance.state === 'unreadable') {
|
|
159
|
+
return [['provenance', `the registry says this version has one and it could not be read — ${provenance.reason}`]];
|
|
160
|
+
}
|
|
161
|
+
const origin = `${provenance.sourceRepository ?? 'an unnamed repository'}`
|
|
162
|
+
+ `${provenance.sourceCommit === null ? '' : ` @ ${provenance.sourceCommit}`}`
|
|
163
|
+
+ `${provenance.sourceRef === null ? '' : ` (${provenance.sourceRef})`}`;
|
|
164
|
+
return [
|
|
165
|
+
['provenance', provenance.state === 'attested'
|
|
166
|
+
? `attested to ${origin}`
|
|
167
|
+
: `ATTESTED TO ${origin}, AND THE ATTESTATION DOES NOT CHECK OUT`],
|
|
168
|
+
['built by', `${provenance.workflow ?? 'an unnamed workflow'}`
|
|
169
|
+
+ `${provenance.builder === null ? '' : ` on ${provenance.builder}`}`],
|
|
170
|
+
['checked', provenance.checks
|
|
171
|
+
.map(check => `${check.passed ? 'ok ' : 'FAIL'} ${check.name} — ${check.detail}`)
|
|
172
|
+
.join(CONTINUATION)],
|
|
173
|
+
['not checked', provenance.notChecked.map(gap => GAP_MEANING[gap]).join(CONTINUATION)],
|
|
174
|
+
];
|
|
175
|
+
}
|
|
126
176
|
/**
|
|
127
177
|
* Render the "what does this plugin do" section, which is printed whether or
|
|
128
178
|
* not there are findings.
|
|
@@ -132,19 +182,24 @@ function describeFileSet(facts) {
|
|
|
132
182
|
*/
|
|
133
183
|
function renderFacts(report, paint) {
|
|
134
184
|
const { facts } = report;
|
|
135
|
-
const
|
|
185
|
+
const registry = report.target.registry;
|
|
136
186
|
const rows = [
|
|
137
187
|
['package', `${facts.packageName}@${facts.packageVersion}${facts.license === null ? '' : ` (${facts.license})`}`],
|
|
138
188
|
['read from', `${report.target.kind} ${report.target.path}`],
|
|
139
|
-
...
|
|
189
|
+
...registry === undefined
|
|
140
190
|
? []
|
|
141
191
|
: [
|
|
142
|
-
['fetched from', `${
|
|
143
|
-
|
|
144
|
-
|
|
192
|
+
['fetched from', `${registry.tarball} (${registry.tarballBytes} bytes, never written to disk)`],
|
|
193
|
+
// Which field matched is not cosmetic: `dist.shasum` is SHA-1 and is
|
|
194
|
+
// only reached on packages published before npm 5, so naming
|
|
195
|
+
// `dist.integrity` there would report a stronger check than ran.
|
|
196
|
+
['verified', `${registry.digest} matched `
|
|
197
|
+
+ `${registry.algorithm === 'sha1' ? 'dist.shasum' : 'dist.integrity'} before anything parsed it`],
|
|
198
|
+
['install script', registry.hasInstallScript
|
|
145
199
|
? 'yes — the registry marks this package as running one at install time'
|
|
146
200
|
: 'no — the registry does not mark this package as running one'],
|
|
147
201
|
],
|
|
202
|
+
...renderProvenance(facts.provenance),
|
|
148
203
|
/* v8 ignore start -- `mountsAsBundle` is true exactly when the manifest declared a path. */
|
|
149
204
|
['mounted layer', facts.mountsAsBundle
|
|
150
205
|
? `yes — dsh.bundle.patch = ${facts.bundlePatchPath ?? '?'} (imported into the harness process at the agent's uid)`
|
|
@@ -153,7 +208,7 @@ function renderFacts(report, paint) {
|
|
|
153
208
|
['browser bundle', facts.shipsClientBundle ? 'yes — dsh.client with an ./client export, executed in the user\'s browser' : 'no'],
|
|
154
209
|
['rows inserted', facts.insertedRows.length === 0
|
|
155
210
|
? 'none'
|
|
156
|
-
: facts.insertedRows.map(row => `${row.id}${row.name === undefined ? '' : ` → ${row.name}`}`).join(
|
|
211
|
+
: facts.insertedRows.map(row => `${row.id}${row.name === undefined ? '' : ` → ${row.name}`}`).join(CONTINUATION)],
|
|
157
212
|
['rows modified', facts.targetedRows.length === 0 ? 'none' : facts.targetedRows.join(', ')],
|
|
158
213
|
['!!js in layer', describeExpressions(facts.jsExpressions)],
|
|
159
214
|
['other layers', facts.unmountedPatchFiles.length === 0
|
package/lib/source.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* cap.
|
|
16
16
|
*
|
|
17
17
|
* Symbolic links are recorded and never followed, for the same reason: a link
|
|
18
|
-
* pointing outside the package is not part of the package.
|
|
18
|
+
* pointing outside the package is not part of the package. Anything else that
|
|
19
|
+
* is not a regular file — a FIFO, a socket, a device node — is recorded the
|
|
20
|
+
* same way and never opened.
|
|
19
21
|
* @module dsh-plugin-inspector/source
|
|
20
22
|
*/
|
|
21
23
|
import { createReadStream, openSync, readdirSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
|
|
@@ -131,8 +133,15 @@ function walkDirectory(root, directory, collector, published) {
|
|
|
131
133
|
walkDirectory(root, absolute, collector, published);
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
134
|
-
if (!entry.isFile())
|
|
136
|
+
if (!entry.isFile()) {
|
|
137
|
+
// A FIFO, socket or device node the publish set includes is content the
|
|
138
|
+
// analyzer did not read, and "how much could be read" is the one number
|
|
139
|
+
// that may never be overstated. Reading one is also not an option: a
|
|
140
|
+
// `readFileSync` on a FIFO blocks until somebody writes to it.
|
|
141
|
+
if (published.includes(path))
|
|
142
|
+
collector.skipped.push({ path, reason: 'unreadable' });
|
|
135
143
|
continue;
|
|
144
|
+
}
|
|
136
145
|
if (!published.includes(path)) {
|
|
137
146
|
collector.unpublished += 1;
|
|
138
147
|
continue;
|
package/lib/syntax.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readings of a syntax tree that both capability detection and readability
|
|
3
|
+
* detection need, and that must agree between them.
|
|
4
|
+
*
|
|
5
|
+
* Tier B matches a name; Tier C reports the names it could not match. If the
|
|
6
|
+
* two disagree about which expressions are constant, a package gets both a
|
|
7
|
+
* finding and a degrade for the same site, or neither. They agree because they
|
|
8
|
+
* ask the same function.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here evaluates anything. {@link foldConstantString} reads literals
|
|
11
|
+
* out of an already-parsed tree and concatenates them; it never constructs a
|
|
12
|
+
* function, and it never touches an identifier's value.
|
|
13
|
+
* @module dsh-plugin-inspector/syntax
|
|
14
|
+
*/
|
|
15
|
+
import ts from 'typescript';
|
|
16
|
+
/**
|
|
17
|
+
* How far {@link foldConstantString} descends before answering `null`.
|
|
18
|
+
*
|
|
19
|
+
* A bound rather than a promise: the folder recurses over attacker-supplied
|
|
20
|
+
* syntax, and a name worth hiding is not hidden eight levels of concatenation
|
|
21
|
+
* deep. Past the bound the answer is "this tool cannot resolve it", which
|
|
22
|
+
* degrades the report rather than dropping the site.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_FOLD_DEPTH = 8;
|
|
25
|
+
/**
|
|
26
|
+
* The separator `Array.prototype.join` uses when called with no argument.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_JOIN_SEPARATOR = ',';
|
|
29
|
+
/**
|
|
30
|
+
* The `Array.prototype.join` case: `['node:child', '_process'].join('')`.
|
|
31
|
+
* @param node - the call expression.
|
|
32
|
+
* @param depth - the current recursion depth.
|
|
33
|
+
* @returns the joined text, or `null` when any part is not a constant.
|
|
34
|
+
*/
|
|
35
|
+
function foldJoin(node, depth) {
|
|
36
|
+
const callee = node.expression;
|
|
37
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'join')
|
|
38
|
+
return null;
|
|
39
|
+
if (!ts.isArrayLiteralExpression(callee.expression))
|
|
40
|
+
return null;
|
|
41
|
+
const separator = node.arguments.length === 0
|
|
42
|
+
? DEFAULT_JOIN_SEPARATOR
|
|
43
|
+
: fold(node.arguments[0], depth + 1);
|
|
44
|
+
if (separator === null)
|
|
45
|
+
return null;
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const element of callee.expression.elements) {
|
|
48
|
+
const part = fold(element, depth + 1);
|
|
49
|
+
if (part === null)
|
|
50
|
+
return null;
|
|
51
|
+
parts.push(part);
|
|
52
|
+
}
|
|
53
|
+
return parts.join(separator);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The template case: `` `node:${'fs'}` ``.
|
|
57
|
+
* @param node - the template expression.
|
|
58
|
+
* @param depth - the current recursion depth.
|
|
59
|
+
* @returns the assembled text, or `null` when any span is not a constant.
|
|
60
|
+
*/
|
|
61
|
+
function foldTemplate(node, depth) {
|
|
62
|
+
let text = node.head.text;
|
|
63
|
+
for (const span of node.templateSpans) {
|
|
64
|
+
const value = fold(span.expression, depth + 1);
|
|
65
|
+
if (value === null)
|
|
66
|
+
return null;
|
|
67
|
+
text += value + span.literal.text;
|
|
68
|
+
}
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The recursive half of {@link foldConstantString}.
|
|
73
|
+
* @param node - the expression to fold.
|
|
74
|
+
* @param depth - the current recursion depth.
|
|
75
|
+
* @returns the text, or `null`.
|
|
76
|
+
*/
|
|
77
|
+
function fold(node, depth) {
|
|
78
|
+
if (node === undefined || depth > MAX_FOLD_DEPTH)
|
|
79
|
+
return null;
|
|
80
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
81
|
+
return node.text;
|
|
82
|
+
if (ts.isParenthesizedExpression(node))
|
|
83
|
+
return fold(node.expression, depth + 1);
|
|
84
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
85
|
+
const left = fold(node.left, depth + 1);
|
|
86
|
+
const right = fold(node.right, depth + 1);
|
|
87
|
+
return left === null || right === null ? null : left + right;
|
|
88
|
+
}
|
|
89
|
+
if (ts.isTemplateExpression(node))
|
|
90
|
+
return foldTemplate(node, depth);
|
|
91
|
+
if (ts.isCallExpression(node))
|
|
92
|
+
return foldJoin(node, depth);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The text a constant string expression holds, or `null` when the expression is
|
|
97
|
+
* not constant.
|
|
98
|
+
*
|
|
99
|
+
* Four forms, chosen because each one is a spelling of a name that a reader
|
|
100
|
+
* sees and a name-matching check does not: a literal, a `+` chain of them, a
|
|
101
|
+
* template whose every span is one, and `[…].join(…)` over an array of them.
|
|
102
|
+
* Anything reaching an identifier, a property, or any other call answers
|
|
103
|
+
* `null` — resolving those is value tracking, which this tool does not do and
|
|
104
|
+
* which Tier C exists to admit.
|
|
105
|
+
* @param node - the expression, or `undefined` for a missing argument.
|
|
106
|
+
* @returns the text, or `null`.
|
|
107
|
+
*/
|
|
108
|
+
export function foldConstantString(node) {
|
|
109
|
+
return fold(node, 0);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The Node API that hands back a builtin module without `require` and without
|
|
113
|
+
* an `import` declaration, added in Node 22.3.
|
|
114
|
+
*
|
|
115
|
+
* It reaches the same modules the harness sandbox's `require` trap covers,
|
|
116
|
+
* from a call that sandbox never sees: the sandbox leaves
|
|
117
|
+
* `process` `undefined`, so inside it this expression throws, and a mounted
|
|
118
|
+
* bundle layer is not inside it.
|
|
119
|
+
* @see https://nodejs.org/api/process.html#processgetbuiltinmoduleid
|
|
120
|
+
*/
|
|
121
|
+
export const BUILTIN_MODULE_GETTER = 'getBuiltinModule';
|
|
122
|
+
/**
|
|
123
|
+
* Whether a call is `process.getBuiltinModule(…)`.
|
|
124
|
+
*
|
|
125
|
+
* The receiver is required. `getBuiltinModule` pulled off `process` and bound
|
|
126
|
+
* to a bare name is not this — it is a detached member, which Tier C reports as
|
|
127
|
+
* dispatch it cannot follow.
|
|
128
|
+
* @param node - the call expression.
|
|
129
|
+
* @returns true when the call loads a builtin through `process`.
|
|
130
|
+
*/
|
|
131
|
+
export function isBuiltinModuleGetter(node) {
|
|
132
|
+
const callee = node.expression;
|
|
133
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === BUILTIN_MODULE_GETTER
|
|
134
|
+
&& ts.isIdentifier(callee.expression) && callee.expression.text === 'process';
|
|
135
|
+
}
|