dsh-plugin-inspector 0.6.0 → 0.8.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
+ }
@@ -20,7 +20,7 @@ import { declaredPackages } from "../manifest.js";
20
20
  * whatever YAML that library happens to ship is inert bytes.
21
21
  */
22
22
  const PATCH_ROW_CHECKS = new Set([
23
- 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A15', 'A17', 'A19', 'A23',
23
+ 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A15', 'A17', 'A19', 'A23', 'A26',
24
24
  ]);
25
25
  /** Loader builtins that are entry names but not resolvable npm packages. */
26
26
  const LOADER_BUILTINS = new Set([
@@ -725,6 +725,113 @@ 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
+ }
768
+ /**
769
+ * A26 — a patch row that modifies a row this package neither ships nor shares
770
+ * with the harness.
771
+ *
772
+ * A2, A3, A5 and A19 all key on {@link CORE_ROWS}: an override whose `id` is
773
+ * not a row the shipped bundles define falls through every one of them and
774
+ * produces nothing. But the composed profile is not only the core rows. It also
775
+ * holds the rows the user wrote in their own layer and the rows every other
776
+ * installed plugin inserted, and `applyEntryPatches` matches by `id` alone with
777
+ * no notion of who owns the row. So `- id: some-other-plugin` / `disabled:
778
+ * true` in this package's layer switches that package off, and a `config:`
779
+ * override replaces its configuration wholesale, since a patch override is a
780
+ * shallow whole-value replacement rather than a merge.
781
+ *
782
+ * The check is a set difference, which is what keeps it decidable: an id that
783
+ * is neither a core row nor a row this same layer inserts belongs to somebody
784
+ * else. Two kinds of package legitimately rewrite rows they did not insert and
785
+ * are excluded — the harness's own bundles, which is what composing a surface
786
+ * bundle is, and a package that declares `dsh.profile.bundles`, which is a
787
+ * profile assembling other people's layers on purpose and is reported as such
788
+ * by A20.
789
+ */
790
+ function checkForeignRows(input) {
791
+ if (isHarnessBundle(input))
792
+ return [];
793
+ if ((input.manifest.dsh.profile?.bundles ?? []).length > 0)
794
+ return [];
795
+ const findings = [];
796
+ for (const patch of input.patches) {
797
+ const own = new Set(patch.inserts.map(row => row.id).filter(id => id !== null));
798
+ for (const override of patch.overrides) {
799
+ if (override.overriddenKeys.length === 0)
800
+ continue;
801
+ if (CORE_ROWS.has(override.id) || own.has(override.id))
802
+ continue;
803
+ const switchedOff = override.overriddenKeys.includes('disabled') && Boolean(override.disabled);
804
+ const rewritten = override.overriddenKeys.filter(key => key !== 'disabled');
805
+ findings.push(tierA({
806
+ checkId: 'A26',
807
+ name: 'foreign-row-modified',
808
+ subject: override.id,
809
+ severity: 'high',
810
+ title: switchedOff
811
+ ? `Patch layer disables the row "${override.id}", which this package does not ship`
812
+ : `Patch layer rewrites ${rewritten.map(key => `\`${key}\``).join(', ')} on the row `
813
+ + `"${override.id}", which this package does not ship`,
814
+ detail: `"${override.id}" is neither a row the shipped bundles define nor one this layer inserts, so it `
815
+ + 'belongs to the user\'s own layer or to another installed plugin. `applyEntryPatches` matches rows by '
816
+ + '`id` alone and has no notion of which layer owns one, so this patch reaches into that package\'s row '
817
+ + 'and '
818
+ + (switchedOff
819
+ ? 'stops it running. Whatever that package contributed — a guard, a listener, an audit sink — is not '
820
+ + 'composed into the profile, and the user\'s own configuration still says it is installed.'
821
+ : 'replaces those keys. An override is a shallow whole-value replacement rather than a merge, so an '
822
+ + 'overridden `config` discards every key that package shipped and keeps only what is written here.')
823
+ + ' If the row id is not present in the composed profile the patch is simply inert, which is the benign '
824
+ + 'reading and the one a reader should check first.',
825
+ evidence: {
826
+ file: patch.file,
827
+ path: switchedOff ? `${override.path}.disabled` : override.path,
828
+ snippet: snippet(override.overriddenKeys.join(', ')),
829
+ },
830
+ }));
831
+ }
832
+ }
833
+ return findings;
834
+ }
728
835
  /**
729
836
  * Run every Tier A check.
730
837
  *
@@ -741,6 +848,7 @@ export function runTierA(input) {
741
848
  ...checkNativeBuild(input),
742
849
  ...checkDisabledRows(input),
743
850
  ...checkOverriddenRows(input),
851
+ ...checkForeignRows(input),
744
852
  ...checkExpressions(input),
745
853
  ...checkPatchFailures(input),
746
854
  ...checkInsertedModules(input),
@@ -749,6 +857,7 @@ export function runTierA(input) {
749
857
  ...checkServiceRemapping(input),
750
858
  ...checkModelVisibleText(input),
751
859
  ...checkInjectionText(input),
860
+ ...checkProvenance(input),
752
861
  ];
753
862
  if (input.mountsAsBundle)
754
863
  return findings;