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/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 checkout's own `packages/bundle/*/package.json`.
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.0-rc.5';
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', 'credentials',
212
- 'directoryPicker', 'e2b', 'fs', 'goals', 'invariants', 'jobs', 'llm', 'lsp',
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
- /** The subset of {@link SEAM_KEYS} whose replacement removes a constraint. */
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', 'subprocess',
224
- 'shell', 'fs', 'tools', 'agentLoop', 'sessionPersistence', 'sessionTelemetry', 'invariants',
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, and `dsh.bundle`;
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. decode in memory and analyse, exactly as the tarball path does.
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: (options.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, ''),
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 provenance = report.target.registry;
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
- ...provenance === undefined
189
+ ...registry === undefined
140
190
  ? []
141
191
  : [
142
- ['fetched from', `${provenance.tarball} (${provenance.tarballBytes} bytes, never written to disk)`],
143
- ['verified', `${provenance.digest} matched dist.integrity before anything parsed it`],
144
- ['install script', provenance.hasInstallScript
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('\n ')],
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
+ }