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 +10 -8
- package/lib/attestation.js +374 -0
- package/lib/checks/tier-a.js +41 -0
- package/lib/checks/tier-b.js +51 -27
- package/lib/checks/tier-c.js +146 -18
- package/lib/index.js +2 -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 +58 -7
- 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 +13 -1
- package/lib/types/npm.d.ts +12 -2
- package/lib/types/registry.d.ts +40 -0
- 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)
|
|
@@ -100,13 +101,14 @@ pnpm run test:coverage
|
|
|
100
101
|
pnpm run test:e2e
|
|
101
102
|
```
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
in `
|
|
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
|
+
}
|
package/lib/checks/tier-a.js
CHANGED
|
@@ -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;
|
package/lib/checks/tier-b.js
CHANGED
|
@@ -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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
86
|
+
* @returns the text, or `null`.
|
|
82
87
|
*/
|
|
83
88
|
function literalText(node) {
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
448
|
-
+ 'the definition through a value this tool does not track — a
|
|
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
|
}
|