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 CHANGED
@@ -45,7 +45,8 @@ node lib/cli.js --help
45
45
  dsh-inspect <target> [options]
46
46
  dsh-inspect --from-npm <name>[@<version>] [options]
47
47
 
48
- --from-npm <spec> Fetch from the registry, verify dist.integrity, analyse in memory.
48
+ --from-npm <spec> Fetch from the registry, verify dist.integrity, read the
49
+ provenance attestation if there is one, analyse in memory.
49
50
  --registry <url> Registry base URL for --from-npm.
50
51
  --json Emit the machine-readable JSON document on stdout.
51
52
  --fail-on <severity> Exit 1 at or above this severity. (default: high)
@@ -100,13 +101,14 @@ pnpm run test:coverage
100
101
  pnpm run test:e2e
101
102
  ```
102
103
 
103
- Severity calibration is pinned against a corpus of forty published packages, in
104
- `tests/ecosystem-baseline.json`. **The sweep is not part of CI** every other workflow here runs
105
- without a network, which is what lets the unit suite claim that analysing a package touches nothing
106
- outside the process so it runs as a weekly cron and on request, and a change that starts firing
107
- on ordinary code does not fail the pull request that makes it. What catches it is the release: the
108
- baseline records the build that measured it and a unit test fails unless that matches the version
109
- in `package.json`, so a version bump is not finished until the sweep has been re-run against it.
104
+ Two checks need a network and are therefore **not part of CI**, which is what lets the unit suite
105
+ claim that analysing a package touches nothing outside the process. Both run on a weekly cron and
106
+ on request. `pnpm run sweep` measures severity calibration against a corpus of forty published
107
+ packages, pinned in `tests/ecosystem-baseline.json`; the baseline records the build that measured
108
+ it and a unit test fails unless that matches the version in `package.json`, so a version bump is
109
+ not finished until the sweep has been re-run against it. `pnpm run sync` diffs the harness ground
110
+ truth in `src/knowledge.ts` against a published harness release, because every Tier A verdict is a
111
+ claim about what one harness version does with a declaration.
110
112
 
111
113
  Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
112
114
  [SECURITY.md](SECURITY.md).
@@ -0,0 +1,374 @@
1
+ /**
2
+ * npm provenance attestations: what the registry says about where a published
3
+ * tarball was built, and how much of that this tool can check for itself.
4
+ *
5
+ * A provenance attestation is a Sigstore bundle holding a DSSE envelope over an
6
+ * in-toto statement. The statement names the tarball by digest and, for a
7
+ * GitHub Actions build, the source repository, the commit, and the workflow
8
+ * file that produced it. npm publishes one when a package is released from a
9
+ * trusted CI environment.
10
+ *
11
+ * **This module is careful about the difference between reading a claim and
12
+ * checking one, because a reader who confuses them is worse off than one who
13
+ * has neither.** Everything it can check runs offline against bytes already in
14
+ * hand, and everything it cannot check is named in the same record:
15
+ *
16
+ * - `subject-digest` — the statement's subject digest is the SHA-512 of the
17
+ * tarball this run analysed. This is the binding that makes the rest mean
18
+ * anything: without it the attestation could be about another version.
19
+ * - `subject-package` — that subject is this package at this version, written
20
+ * as a package URL.
21
+ * - `dsse-signature` — the envelope's signature verifies under the public key
22
+ * of the certificate carried inside the bundle.
23
+ * - `certificate-identity` — the certificate's subject alternative name is the
24
+ * workflow the statement claims built the package, so payload and signer
25
+ * agree.
26
+ *
27
+ * What it does **not** do is establish that the certificate is Sigstore's. That
28
+ * needs the Fulcio root, which lives in the Sigstore trust root and not on any
29
+ * npm registry, and the same root is what a Rekor inclusion proof is checked
30
+ * against. Without it these four checks prove the bundle is internally
31
+ * consistent and is about these exact bytes — not that the identity in it is
32
+ * real. Those gaps are listed in {@link ProvenanceFact.notChecked} and printed
33
+ * in the report, so no reader has to infer them.
34
+ * @module dsh-plugin-inspector/attestation
35
+ */
36
+ import { X509Certificate, createHash, createVerify } from 'node:crypto';
37
+ /** The SLSA predicate type npm publishes a build provenance statement under. */
38
+ export const PROVENANCE_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
39
+ /**
40
+ * Largest attestation document the tool will read. Real bundles are tens of
41
+ * kilobytes; the transparency-log inclusion proof is most of that.
42
+ */
43
+ export const MAX_ATTESTATION_BYTES = 1024 * 1024;
44
+ /** Gaps that hold for every attestation, whatever is in it. */
45
+ const UNCONDITIONAL_GAPS = ['certificate-chain', 'transparency-log'];
46
+ /** A fact carrying no attestation, with every claim spelled out as absent. */
47
+ const NOTHING = {
48
+ predicateType: null,
49
+ sourceRepository: null,
50
+ sourceCommit: null,
51
+ sourceRef: null,
52
+ workflow: null,
53
+ builder: null,
54
+ signerIdentity: null,
55
+ checks: [],
56
+ notChecked: [],
57
+ };
58
+ /**
59
+ * The fact for a target that has no registry statement: a directory, or a
60
+ * tarball on disk. Stated rather than reported as an absence, because "this
61
+ * package has no provenance" and "this mode cannot read provenance" are
62
+ * different answers and only one of them is about the package.
63
+ * @returns the fact.
64
+ */
65
+ export function provenanceUnavailable() {
66
+ return {
67
+ ...NOTHING,
68
+ state: 'unavailable',
69
+ reason: 'a directory or local tarball carries no registry attestation; --from-npm reads one',
70
+ attestationUrl: null,
71
+ };
72
+ }
73
+ /**
74
+ * The fact for a published package whose version document declares no
75
+ * provenance attestation.
76
+ * @returns the fact.
77
+ */
78
+ export function provenanceAbsent() {
79
+ return { ...NOTHING, state: 'absent', reason: null, attestationUrl: null };
80
+ }
81
+ /**
82
+ * The fact for an attestation the registry named but this run could not read.
83
+ * @param url - the endpoint that was asked, or `null` when none could be built.
84
+ * @param reason - what went wrong.
85
+ * @returns the fact.
86
+ */
87
+ export function provenanceUnreadable(url, reason) {
88
+ return { ...NOTHING, state: 'unreadable', reason, attestationUrl: url };
89
+ }
90
+ /**
91
+ * Narrow an unknown JSON value to a record, so a hostile document's
92
+ * `attestations: 7` reads as "no fields" rather than throwing further down.
93
+ * @param value - the parsed JSON value.
94
+ * @returns the value as a record, or an empty one.
95
+ */
96
+ function asRecord(value) {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
98
+ ? value
99
+ : {};
100
+ }
101
+ /**
102
+ * Read a string field, or `null` when it is absent or of another type.
103
+ * @param record - the containing record.
104
+ * @param key - the field name.
105
+ * @returns the string, or `null`.
106
+ */
107
+ function asString(record, key) {
108
+ const value = record[key];
109
+ return typeof value === 'string' ? value : null;
110
+ }
111
+ /**
112
+ * The package URL npm writes an attestation subject's name as. A scope's `@`
113
+ * is percent-encoded there and nowhere else in the document.
114
+ * @param name - the package name.
115
+ * @param version - the resolved version.
116
+ * @returns the package URL.
117
+ */
118
+ export function packageUrl(name, version) {
119
+ return `pkg:npm/${name.replace(/^@/, '%40')}@${version}`;
120
+ }
121
+ /**
122
+ * The pre-authentication encoding DSSE signs: the payload type and the payload,
123
+ * each preceded by its length, so neither can be shifted into the other.
124
+ * @param payloadType - the envelope's declared payload type.
125
+ * @param payload - the decoded payload.
126
+ * @returns the bytes the signature covers.
127
+ */
128
+ function preAuthenticationEncoding(payloadType, payload) {
129
+ const type = Buffer.from(payloadType, 'utf8');
130
+ return Buffer.concat([
131
+ Buffer.from(`DSSEv1 ${type.length} `, 'utf8'),
132
+ type,
133
+ Buffer.from(` ${payload.length} `, 'utf8'),
134
+ payload,
135
+ ]);
136
+ }
137
+ /**
138
+ * The signing certificate a Sigstore bundle carries. Bundle media type 0.2
139
+ * carries a chain and 0.3 carries the leaf alone; npm publishes both, so both
140
+ * are read.
141
+ * @param material - the bundle's `verificationMaterial`.
142
+ * @returns the leaf certificate's DER bytes, or `null`.
143
+ */
144
+ function leafCertificate(material) {
145
+ const single = asString(asRecord(material.certificate), 'rawBytes');
146
+ if (single !== null)
147
+ return Buffer.from(single, 'base64');
148
+ const chain = asRecord(material.x509CertificateChain).certificates;
149
+ if (!Array.isArray(chain))
150
+ return null;
151
+ const first = asString(asRecord(chain[0]), 'rawBytes');
152
+ return first === null ? null : Buffer.from(first, 'base64');
153
+ }
154
+ /** Raised for an attestation document this tool cannot read at all. */
155
+ class UnreadableAttestation extends Error {
156
+ }
157
+ /**
158
+ * Pull the provenance bundle out of a registry attestation document.
159
+ * @param body - the document as served.
160
+ * @returns the parts the checks read.
161
+ * @throws UnreadableAttestation when the document holds no readable provenance bundle.
162
+ */
163
+ function decode(body) {
164
+ let document;
165
+ try {
166
+ document = JSON.parse(body.toString('utf8'));
167
+ }
168
+ catch (error) {
169
+ /* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
170
+ const detail = error instanceof Error ? error.message : String(error);
171
+ throw new UnreadableAttestation(`the attestation endpoint did not return JSON: ${detail}`);
172
+ }
173
+ const list = asRecord(document).attestations;
174
+ const entries = Array.isArray(list) ? list.map(asRecord) : [];
175
+ const entry = entries.find(candidate => asString(candidate, 'predicateType') === PROVENANCE_PREDICATE_TYPE);
176
+ if (entry === undefined) {
177
+ throw new UnreadableAttestation(`the document carries no ${PROVENANCE_PREDICATE_TYPE} attestation`);
178
+ }
179
+ const bundle = asRecord(entry.bundle);
180
+ const der = leafCertificate(asRecord(bundle.verificationMaterial));
181
+ if (der === null)
182
+ throw new UnreadableAttestation('the bundle carries no signing certificate');
183
+ let certificate;
184
+ try {
185
+ certificate = new X509Certificate(der);
186
+ }
187
+ catch (error) {
188
+ /* v8 ignore next -- X509Certificate rejects bytes only with an Error. */
189
+ const detail = error instanceof Error ? error.message : String(error);
190
+ throw new UnreadableAttestation(`the bundle's signing certificate does not parse: ${detail}`);
191
+ }
192
+ const envelope = asRecord(bundle.dsseEnvelope);
193
+ const encoded = asString(envelope, 'payload');
194
+ const payloadType = asString(envelope, 'payloadType');
195
+ const signatures = envelope.signatures;
196
+ const signature = Array.isArray(signatures) ? asString(asRecord(signatures[0]), 'sig') : null;
197
+ if (encoded === null || payloadType === null || signature === null) {
198
+ throw new UnreadableAttestation('the bundle carries no complete DSSE envelope');
199
+ }
200
+ const payload = Buffer.from(encoded, 'base64');
201
+ let statement;
202
+ try {
203
+ statement = JSON.parse(payload.toString('utf8'));
204
+ }
205
+ catch (error) {
206
+ /* v8 ignore next -- JSON.parse rejects text only with a SyntaxError. */
207
+ const detail = error instanceof Error ? error.message : String(error);
208
+ throw new UnreadableAttestation(`the signed statement is not JSON: ${detail}`);
209
+ }
210
+ return {
211
+ certificate,
212
+ payload,
213
+ payloadType,
214
+ signature: Buffer.from(signature, 'base64'),
215
+ statement: asRecord(statement),
216
+ };
217
+ }
218
+ /**
219
+ * Read the build claims out of a SLSA v1 predicate.
220
+ * @param statement - the decoded in-toto statement.
221
+ * @returns what it says about the source and the builder.
222
+ */
223
+ function readClaims(statement) {
224
+ const predicate = asRecord(statement.predicate);
225
+ const definition = asRecord(predicate.buildDefinition);
226
+ const workflow = asRecord(asRecord(definition.externalParameters).workflow);
227
+ const dependencies = definition.resolvedDependencies;
228
+ const source = asRecord(Array.isArray(dependencies) ? dependencies[0] : undefined);
229
+ return {
230
+ repository: asString(workflow, 'repository'),
231
+ commit: asString(asRecord(source.digest), 'gitCommit'),
232
+ ref: asString(workflow, 'ref'),
233
+ workflow: asString(workflow, 'path'),
234
+ builder: asString(asRecord(asRecord(predicate.runDetails).builder), 'id'),
235
+ };
236
+ }
237
+ /**
238
+ * Every subject the statement names, as name and SHA-512 pairs.
239
+ * @param statement - the decoded in-toto statement.
240
+ * @returns one entry per subject the document could be read for.
241
+ */
242
+ function readSubjects(statement) {
243
+ const subjects = statement.subject;
244
+ if (!Array.isArray(subjects))
245
+ return [];
246
+ return subjects.map(asRecord).map(subject => ({
247
+ name: asString(subject, 'name'),
248
+ sha512: asString(asRecord(subject.digest), 'sha512'),
249
+ }));
250
+ }
251
+ /**
252
+ * Verify the DSSE signature under the public key of the certificate in the
253
+ * bundle.
254
+ *
255
+ * A failure here is a bundle whose payload and signature disagree. A success
256
+ * says only that; it says nothing about who holds the key, which is the gap
257
+ * `certificate-chain` names.
258
+ * @param bundle - the decoded bundle.
259
+ * @returns the check result.
260
+ */
261
+ function checkSignature(bundle) {
262
+ const signed = preAuthenticationEncoding(bundle.payloadType, bundle.payload);
263
+ let passed;
264
+ try {
265
+ const verifier = createVerify('sha256');
266
+ verifier.update(signed);
267
+ verifier.end();
268
+ passed = verifier.verify(bundle.certificate.publicKey, bundle.signature);
269
+ }
270
+ catch (error) {
271
+ /* v8 ignore next -- `verify` refuses a key it cannot use only with an Error. */
272
+ const detail = error instanceof Error ? error.message : String(error);
273
+ return {
274
+ name: 'dsse-signature',
275
+ detail: `the signature could not be checked against the bundle's certificate: ${detail}`,
276
+ passed: false,
277
+ };
278
+ }
279
+ return {
280
+ name: 'dsse-signature',
281
+ detail: passed
282
+ ? 'the DSSE signature verifies under the public key of the certificate in the bundle'
283
+ : 'the DSSE signature does not verify under the public key of the certificate in the bundle',
284
+ passed,
285
+ };
286
+ }
287
+ /**
288
+ * Compare the certificate's subject alternative name against the workflow the
289
+ * statement claims. Fulcio writes the workflow identity there as
290
+ * `<repository>/<path>@<ref>`, so the two are the same claim from two places in
291
+ * the bundle and a mismatch means one of them was swapped.
292
+ * @param bundle - the decoded bundle.
293
+ * @param claims - what the statement says about the build.
294
+ * @returns the check, or `null` when the statement names no workflow to compare.
295
+ */
296
+ function checkIdentity(bundle, claims) {
297
+ if (claims.repository === null || claims.workflow === null || claims.ref === null)
298
+ return null;
299
+ const expected = `${claims.repository}/${claims.workflow}@${claims.ref}`;
300
+ const names = (bundle.certificate.subjectAltName ?? '').split(', ');
301
+ const passed = names.includes(`URI:${expected}`);
302
+ return {
303
+ name: 'certificate-identity',
304
+ detail: passed
305
+ ? `the signing certificate names ${expected}, which is the workflow the statement claims`
306
+ : `the statement claims ${expected} but the signing certificate names `
307
+ + `${bundle.certificate.subjectAltName ?? 'nothing'}`,
308
+ passed,
309
+ };
310
+ }
311
+ /**
312
+ * Check a fetched attestation document against the tarball this run analysed.
313
+ *
314
+ * The digest comparison is the load-bearing one: it is what ties the statement
315
+ * to these bytes rather than to some other version of the same package. The
316
+ * bytes handed in are the ones `dist.integrity` already vouched for, so a pass
317
+ * here means one chain of digests runs from the version document through the
318
+ * download to the signed statement.
319
+ * @param body - the attestation document as served.
320
+ * @param subject - the package the document is supposed to be about.
321
+ * @returns the fact, in state `attested`, `failed`, or `unreadable`.
322
+ */
323
+ export function readProvenance(body, subject) {
324
+ let bundle;
325
+ try {
326
+ bundle = decode(body);
327
+ }
328
+ catch (error) {
329
+ /* v8 ignore next -- `decode` reports every refusal as an UnreadableAttestation. */
330
+ if (!(error instanceof UnreadableAttestation))
331
+ throw error;
332
+ return provenanceUnreadable(subject.url, error.message);
333
+ }
334
+ const claims = readClaims(bundle.statement);
335
+ const subjects = readSubjects(bundle.statement);
336
+ const digest = createHash('sha512').update(subject.tarball).digest('hex');
337
+ const expectedName = packageUrl(subject.name, subject.version);
338
+ const digestMatched = subjects.some(entry => entry.sha512 === digest);
339
+ const nameMatched = subjects.some(entry => entry.name === expectedName);
340
+ const identity = checkIdentity(bundle, claims);
341
+ const checks = [
342
+ {
343
+ name: 'subject-digest',
344
+ detail: digestMatched
345
+ ? 'the statement covers the exact bytes this run analysed, by SHA-512'
346
+ : `the statement covers no artifact with the SHA-512 of the downloaded tarball (${digest})`,
347
+ passed: digestMatched,
348
+ },
349
+ {
350
+ name: 'subject-package',
351
+ detail: nameMatched
352
+ ? `the statement is about ${expectedName}`
353
+ : `the statement names ${subjects.map(entry => entry.name ?? '(unnamed)').join(', ') || 'no subject'}`
354
+ + `, not ${expectedName}`,
355
+ passed: nameMatched,
356
+ },
357
+ checkSignature(bundle),
358
+ ...identity === null ? [] : [identity],
359
+ ];
360
+ return {
361
+ state: checks.every(check => check.passed) ? 'attested' : 'failed',
362
+ reason: null,
363
+ predicateType: PROVENANCE_PREDICATE_TYPE,
364
+ sourceRepository: claims.repository,
365
+ sourceCommit: claims.commit,
366
+ sourceRef: claims.ref,
367
+ workflow: claims.workflow,
368
+ builder: claims.builder,
369
+ signerIdentity: bundle.certificate.subjectAltName ?? null,
370
+ attestationUrl: subject.url,
371
+ checks,
372
+ notChecked: identity === null ? [...UNCONDITIONAL_GAPS, 'builder-identity'] : UNCONDITIONAL_GAPS,
373
+ };
374
+ }
@@ -725,6 +725,46 @@ function checkInjectionText(input) {
725
725
  }
726
726
  return findings;
727
727
  }
728
+ /**
729
+ * A25 — a provenance attestation that does not check out.
730
+ *
731
+ * Only a `failed` fact reaches here, and `failed` means the registry published
732
+ * an attestation for these bytes and one of the checks in `attestation.ts`
733
+ * contradicted it. That is a narrow claim on purpose:
734
+ *
735
+ * - **Absence is not this finding, and no severity fires on it.** 28 of the 40
736
+ * packages in the pinned corpus publish no attestation at all. A finding on
737
+ * the majority of legitimate packages is the miscalibration the 0.2 release
738
+ * existed to remove, so absence is reported as a fact and nothing else.
739
+ * - **This is not an attack detector.** A publisher who wants no provenance
740
+ * simply publishes none, which produces no finding, so nothing here can be
741
+ * evaded by making it fail. What it catches is an attestation that is real
742
+ * and does not describe this download: the wrong artifact, a mirror serving
743
+ * one version's bundle for another, or a payload edited after signing.
744
+ * - It is Tier A because it is decidable. The comparisons are digests and a
745
+ * signature over bytes already in hand, with no heuristic anywhere in them.
746
+ */
747
+ function checkProvenance(input) {
748
+ const { provenance } = input;
749
+ if (provenance.state !== 'failed')
750
+ return [];
751
+ const failed = provenance.checks.filter(check => !check.passed);
752
+ return failed.map(check => tierA({
753
+ checkId: 'A25',
754
+ name: 'provenance-mismatch',
755
+ subject: check.name,
756
+ severity: 'high',
757
+ title: `Provenance attestation fails its \`${check.name}\` check`,
758
+ detail: `The registry published a build provenance attestation for this version and it does not hold: `
759
+ + `${check.detail}. The attestation is at ${provenance.attestationUrl}. `
760
+ + 'A published attestation that does not describe the downloaded artifact is worth more attention than no '
761
+ + 'attestation at all, because a reader who saw the badge and not this line would conclude the opposite. '
762
+ + 'What this does NOT establish is that the signing certificate is genuinely Sigstore\'s: that needs a '
763
+ + 'trust root this tool does not carry, so a registry that serves a doctored bundle can make every other '
764
+ + 'check pass.',
765
+ evidence: { file: 'dist.attestations', path: check.name },
766
+ }));
767
+ }
728
768
  /**
729
769
  * Run every Tier A check.
730
770
  *
@@ -749,6 +789,7 @@ export function runTierA(input) {
749
789
  ...checkServiceRemapping(input),
750
790
  ...checkModelVisibleText(input),
751
791
  ...checkInjectionText(input),
792
+ ...checkProvenance(input),
752
793
  ];
753
794
  if (input.mountsAsBundle)
754
795
  return findings;
@@ -16,6 +16,7 @@ import ts from 'typescript';
16
16
  import { lineColumn, snippet } from "../files.js";
17
17
  import { scanInjection } from "../injection.js";
18
18
  import { NETWORK_MODULES, SEAM_KEYS, SECURITY_SEAM_KEYS, UNMEDIATED_FS_MODULES, UNMEDIATED_PROCESS_MODULES, } from "../knowledge.js";
19
+ import { foldConstantString, isBuiltinModuleGetter } from "../syntax.js";
19
20
  /** Global functions that fetch over the network without any `ctx` service. */
20
21
  const NETWORK_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource', 'XMLHttpRequest']);
21
22
  /** `process.env` keys whose names say they hold a secret. */
@@ -74,18 +75,18 @@ function tierB(finding) {
74
75
  return { ...finding, tier: 'B', confidence: 'high', examples: [finding.evidence], occurrences: 1 };
75
76
  }
76
77
  /**
77
- * The literal text of a string argument, or `null` when it is computed.
78
- * A computed argument is not a Tier B miss to paper over — it is a Tier C
79
- * signal, and `tier-c.ts` records it.
78
+ * The text a string argument holds, folding the constant forms a `+` chain of
79
+ * literals, a template whose spans are literals, `[…].join(…)` over literals.
80
+ *
81
+ * Folding is bounded on purpose. An argument this cannot resolve is not a
82
+ * Tier B miss to paper over: it is a Tier C signal, and `tier-c.ts` records it
83
+ * by asking the same folder, so a site is either matched here or degraded
84
+ * there and never both.
80
85
  * @param node - the argument expression.
81
- * @returns the literal text, or `null`.
86
+ * @returns the text, or `null`.
82
87
  */
83
88
  function literalText(node) {
84
- if (node === undefined)
85
- return null;
86
- if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
87
- return node.text;
88
- return null;
89
+ return foldConstantString(node);
89
90
  }
90
91
  /**
91
92
  * Strip the `node:` prefix so `node:fs` and `fs` compare equal.
@@ -96,9 +97,13 @@ function bareModule(specifier) {
96
97
  return specifier.startsWith('node:') ? specifier.slice(5) : specifier;
97
98
  }
98
99
  /**
99
- * Every module specifier the file imports or requires, as literal text.
100
+ * Every module the file reaches by a name this tool can resolve.
101
+ *
102
+ * Three ways in, not two. `import` and `require` are the declarations a reader
103
+ * looks for; `process.getBuiltinModule('node:fs')` is a third that needs
104
+ * neither, returns the same module object, and appears in no import list.
100
105
  * @param file - the parsed file.
101
- * @returns specifier text paired with the node it came from.
106
+ * @returns one entry per resolved reference.
102
107
  */
103
108
  function moduleSpecifiers(file) {
104
109
  const found = [];
@@ -107,15 +112,16 @@ function moduleSpecifiers(file) {
107
112
  const text = literalText(node.moduleSpecifier);
108
113
  /* v8 ignore next -- an import declaration only parses with a string-literal specifier. */
109
114
  if (text !== null)
110
- found.push({ specifier: text, node });
115
+ found.push({ specifier: text, node, via: 'import' });
111
116
  }
112
117
  if (ts.isCallExpression(node)) {
113
118
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
114
119
  const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
115
- if (isRequire || isImport) {
120
+ const isBuiltin = isBuiltinModuleGetter(node);
121
+ if (isRequire || isImport || isBuiltin) {
116
122
  const text = literalText(node.arguments[0]);
117
123
  if (text !== null)
118
- found.push({ specifier: text, node });
124
+ found.push({ specifier: text, node, via: isBuiltin ? 'builtin-getter' : 'import' });
119
125
  }
120
126
  }
121
127
  ts.forEachChild(node, visit);
@@ -136,9 +142,25 @@ function at(file, node) {
136
142
  snippet: snippet(file.text.slice(node.getStart(file.node), node.end)),
137
143
  };
138
144
  }
139
- /** B9, B7, B13 — what the file imports. */
145
+ /**
146
+ * How a finding names the way a module was reached.
147
+ *
148
+ * `Imports` would be false of `process.getBuiltinModule('node:fs')`, and the
149
+ * difference is the point of covering it: the module arrives with no import
150
+ * declaration and no `require` for a reader to find.
151
+ * @param reference - the resolved module reference.
152
+ * @returns the opening clause of the finding's title.
153
+ */
154
+ function reachedBy(reference) {
155
+ return reference.via === 'import'
156
+ ? `Imports \`${reference.specifier}\``
157
+ : `Loads \`${reference.specifier}\` through \`process.getBuiltinModule\``;
158
+ }
159
+ /** B9, B7, B13 — what modules the file reaches. */
140
160
  function checkImports(file, accumulator) {
141
- for (const { specifier, node } of moduleSpecifiers(file)) {
161
+ for (const reference of moduleSpecifiers(file)) {
162
+ const { specifier, node } = reference;
163
+ const reached = reachedBy(reference);
142
164
  const bare = bareModule(specifier);
143
165
  const unmediated = UNMEDIATED_PROCESS_MODULES.get(bare);
144
166
  if (unmediated !== undefined) {
@@ -150,12 +172,13 @@ function checkImports(file, accumulator) {
150
172
  // reads a credential or reaches the network. On its own it is a
151
173
  // capability half the ecosystem has.
152
174
  severity: 'medium',
153
- title: `Imports \`${specifier}\`, which ${unmediated}`,
175
+ title: `${reached}, which ${unmediated}`,
154
176
  detail: 'A mounted bundle layer is imported into the harness process at the agent\'s uid. The harness\'s own '
155
177
  + 'dynamic-package sandbox denies untrusted code `require` outright and redirects it to ctx services; a '
156
178
  + 'bundle layer gets no such restriction, so this import does exactly what the harness forbids elsewhere.',
157
179
  evidence: at(file, node),
158
- bypass: 'a computed specifier — `await import(["node","child_process"].join(":"))` is not matched, which is why C2 downgrades every Tier B negative',
180
+ bypass: 'a specifier this tool cannot fold to a constant — `import(name)` against a binding which C2 '
181
+ + 'reports, so the negative degrades rather than passing quietly',
159
182
  }));
160
183
  }
161
184
  if (NETWORK_MODULES.has(bare)) {
@@ -164,11 +187,11 @@ function checkImports(file, accumulator) {
164
187
  name: 'network-egress',
165
188
  subject: specifier,
166
189
  severity: 'medium',
167
- title: `Imports \`${specifier}\`, which can move bytes off the machine`,
190
+ title: `${reached}, which can move bytes off the machine`,
168
191
  detail: 'Network access is a capability, not a verdict: most plugins that reach the network do so for a '
169
192
  + 'declared reason. It is recorded because paired with a credential read it becomes B8.',
170
193
  evidence: at(file, node),
171
- bypass: 'a computed specifier, or a transitive dependency doing the request on this package\'s behalf',
194
+ bypass: 'a transitive dependency doing the request on this package\'s behalf',
172
195
  });
173
196
  accumulator.findings.push(finding);
174
197
  accumulator.networkCall ??= finding;
@@ -179,12 +202,12 @@ function checkImports(file, accumulator) {
179
202
  name: 'unmediated-filesystem',
180
203
  subject: specifier,
181
204
  severity: 'medium',
182
- title: `Imports \`${specifier}\` rather than using the \`ctx.fs\` service`,
205
+ title: `${reached} rather than using the \`ctx.fs\` service`,
183
206
  detail: 'Reads and writes through the Node filesystem API are invisible to `fs/write-intent`, '
184
207
  + '`fs/edit-intent`, `fs/observed`, and the `fs-sandbox` row, so no policy in the profile sees them and '
185
208
  + 'nothing appears in the session log.',
186
209
  evidence: at(file, node),
187
- bypass: 'a computed specifier, or `process.getBuiltinModule("node:fs")`',
210
+ bypass: 'a transitive dependency reading the file on this package\'s behalf',
188
211
  }));
189
212
  }
190
213
  }
@@ -210,7 +233,8 @@ function checkSeamReplacement(file, node, accumulator) {
210
233
  + 'package\'s implementation for every consumer in the scope, and consumers cannot tell the difference.'
211
234
  + (critical ? ' This seam is one whose whole purpose is to constrain what the agent may do.' : ''),
212
235
  evidence: at(file, node),
213
- bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched",
236
+ bypass: "`ctx['pro' + 'vide']('approval', …)` — a computed member name is not matched — and neither is a "
237
+ + '`provide` destructured off `ctx` and called through the bare name. C2 reports both',
214
238
  }));
215
239
  }
216
240
  /** B5 — changing what the model is told. */
@@ -352,7 +376,7 @@ function checkCredentialRead(file, node, accumulator) {
352
376
  detail: 'Reading a credential is a capability, not a verdict — a plugin that authenticates to its own service '
353
377
  + 'must do it. It is recorded because paired with network access it becomes B8.',
354
378
  evidence: at(file, node),
355
- bypass: 'a computed key `process.env["API"+"_KEY"]` or reading the whole `process.env` object and indexing it later',
379
+ bypass: 'a key this tool cannot fold to a constant, or reading the whole `process.env` object and indexing it later',
356
380
  });
357
381
  accumulator.findings.push(finding);
358
382
  accumulator.credentialRead ??= finding;
@@ -444,9 +468,9 @@ function checkToolDescription(file, node, accumulator) {
444
468
  + 'heuristic: it will miss a rephrasing, and it can fire on a description that legitimately discusses the '
445
469
  + 'subject.',
446
470
  evidence: { ...at(file, node), snippet: snippet(match.excerpt) },
447
- bypass: 'any rephrasing the pattern does not cover, building the description by concatenation, or registering '
448
- + 'the definition through a value this tool does not track — a definition exported from one file and passed '
449
- + 'to `tools.register` in another is not matched',
471
+ bypass: 'any rephrasing the pattern does not cover, assembling the description out of anything this tool '
472
+ + 'cannot fold to a constant, or registering the definition through a value this tool does not track — a '
473
+ + 'definition exported from one file and passed to `tools.register` in another is not matched',
450
474
  }));
451
475
  }
452
476
  }