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/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,
|
|
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)
|
|
@@ -84,8 +85,8 @@ Findings are tiered by how much you should trust them:
|
|
|
84
85
|
intent is not. Every Tier B check has a one-line bypass, and the tool says so per finding rather
|
|
85
86
|
than implying a completeness it does not have. What it does guarantee is that it never runs the
|
|
86
87
|
code it analyses — asserted from outside the unit suite by a CI canary whose fixture writes
|
|
87
|
-
sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config
|
|
88
|
-
sentinel on disk after a full analysis is a release blocker.
|
|
88
|
+
sentinel files from `preinstall`, `postinstall`, `prepare`, `!!js` config, `!!js` disabled, and
|
|
89
|
+
module top level. Any sentinel on disk after a full analysis is a release blocker.
|
|
89
90
|
|
|
90
91
|
[What is not statically decidable →](https://charlotten7.github.io/dsh-plugin-inspector/ceiling.html) ·
|
|
91
92
|
[What it reports on the real ecosystem →](https://charlotten7.github.io/dsh-plugin-inspector/ecosystem.html)
|
|
@@ -100,8 +101,14 @@ pnpm run test:coverage
|
|
|
100
101
|
pnpm run test:e2e
|
|
101
102
|
```
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
105
112
|
|
|
106
113
|
Design decisions and their rationale live in [ADR.md](ADR.md). Security policy is in
|
|
107
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
|
+
}
|
package/lib/checks/tier-a.js
CHANGED
|
@@ -123,8 +123,12 @@ function checkDisabledRows(input) {
|
|
|
123
123
|
// (vendor/loader/src/config/entry.ts). `null`, `0` and `""` therefore
|
|
124
124
|
// leave the row running, and reporting them as a disabled row would be
|
|
125
125
|
// confidently wrong about the one thing Tier A claims to be certain of.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
126
|
+
// A `!!js` node is not that case. The loader evaluates the expression
|
|
127
|
+
// first and coerces its *result*, so `disabled: !!js false` also leaves
|
|
128
|
+
// the row running — which this tool cannot know without running the
|
|
129
|
+
// expression, and running it is the one thing it may never do. An
|
|
130
|
+
// expression is therefore read as what it could evaluate to, which
|
|
131
|
+
// raises the finding rather than dropping it.
|
|
128
132
|
if (!override.disabled) {
|
|
129
133
|
const enabled = coreRowSeverity(override.id);
|
|
130
134
|
if (enabled === null)
|
|
@@ -643,7 +647,7 @@ function checkModelVisibleText(input) {
|
|
|
643
647
|
detail: 'Skill and agent-instruction markdown reaches the model verbatim, unescaped and uncapped. Shipping it '
|
|
644
648
|
+ 'in an npm package does not by itself put it in front of the model: it is discovered only when the plugin '
|
|
645
649
|
+ 'registers it through ctx.skills, when a patch row redirects a skill root into this package (A15), or when '
|
|
646
|
-
+ 'something copies it into the user\'s workspace. The text itself is scored separately by
|
|
650
|
+
+ 'something copies it into the user\'s workspace. The text itself is scored separately by A21.',
|
|
647
651
|
/* v8 ignore next -- the caller returns early on an empty list. */
|
|
648
652
|
evidence: { file: input.modelVisibleFiles[0] ?? '', snippet: snippet(input.modelVisibleFiles.join(', ')) },
|
|
649
653
|
})];
|
|
@@ -721,6 +725,46 @@ function checkInjectionText(input) {
|
|
|
721
725
|
}
|
|
722
726
|
return findings;
|
|
723
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
|
+
}
|
|
724
768
|
/**
|
|
725
769
|
* Run every Tier A check.
|
|
726
770
|
*
|
|
@@ -745,6 +789,7 @@ export function runTierA(input) {
|
|
|
745
789
|
...checkServiceRemapping(input),
|
|
746
790
|
...checkModelVisibleText(input),
|
|
747
791
|
...checkInjectionText(input),
|
|
792
|
+
...checkProvenance(input),
|
|
748
793
|
];
|
|
749
794
|
if (input.mountsAsBundle)
|
|
750
795
|
return findings;
|