@dogfood-lab/verify 1.4.0 → 1.6.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 +9 -3
- package/cli.js +447 -0
- package/index.js +50 -11
- package/package.json +8 -1
- package/parse-rejection.js +146 -0
- package/validators/policy.js +83 -3
- package/validators/provenance-gitlab.test.js +375 -0
- package/validators/provenance-registry.test.js +56 -0
- package/validators/provenance.js +331 -31
- package/validators/repo-binding.js +92 -0
- package/validators/repo-binding.test.js +118 -0
- package/validators/schema.js +8 -17
- package/validators/steps.js +5 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provenance-registry.test.js — provenance adapter coverage (peer of repo-binding.test.js)
|
|
3
|
+
*
|
|
4
|
+
* The forgery-vector tripwire in repo-binding.test.js asserts every
|
|
5
|
+
* `source.provider` enum member has a run_url PARSER. This file closes the other
|
|
6
|
+
* half of the same discipline: every provider must ALSO have a provenance
|
|
7
|
+
* ADAPTER in PROVENANCE_ADAPTERS. A provider with a parser but no adapter would
|
|
8
|
+
* be selectable at the binding layer yet un-confirmable at the provenance layer
|
|
9
|
+
* — submissions for it would fail closed with an opaque "no adapter" path. The
|
|
10
|
+
* coverage test reads the ACTUAL provider enum from the canonical submission
|
|
11
|
+
* schema, so adding a provider without an adapter goes RED in CI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
import { PROVENANCE_ADAPTERS, provenanceForProvider, githubProvenance, gitlabProvenance } from './provenance.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const schemaPath = require.resolve(
|
|
23
|
+
'@dogfood-lab/schemas/json/dogfood-record-submission.schema.json'
|
|
24
|
+
);
|
|
25
|
+
const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
26
|
+
const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
|
|
27
|
+
|
|
28
|
+
describe('provenance adapter coverage', () => {
|
|
29
|
+
it('exposes the provider enum it is meant to cover (sanity)', () => {
|
|
30
|
+
assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
|
|
31
|
+
assert.ok(PROVIDER_ENUM.includes('github'));
|
|
32
|
+
assert.ok(PROVIDER_ENUM.includes('gitlab'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('has a provenance adapter factory for EVERY registered source.provider', () => {
|
|
36
|
+
const missing = PROVIDER_ENUM.filter(p => typeof PROVENANCE_ADAPTERS[p] !== 'function');
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
missing,
|
|
39
|
+
[],
|
|
40
|
+
`source.provider enum members without a PROVENANCE_ADAPTERS factory: ${missing.join(', ')}. ` +
|
|
41
|
+
'Add an adapter in validators/provenance.js or provenance falls closed for these providers ' +
|
|
42
|
+
'with no confirmation path.'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('provenanceForProvider returns the matching factory', () => {
|
|
47
|
+
assert.equal(PROVENANCE_ADAPTERS.github, githubProvenance);
|
|
48
|
+
assert.equal(PROVENANCE_ADAPTERS.gitlab, gitlabProvenance);
|
|
49
|
+
assert.equal(provenanceForProvider('github'), githubProvenance);
|
|
50
|
+
assert.equal(provenanceForProvider('gitlab'), gitlabProvenance);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('provenanceForProvider returns null for an unknown provider (no throw)', () => {
|
|
54
|
+
assert.equal(provenanceForProvider('bitbucket'), null);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/validators/provenance.js
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
* Provenance adapters
|
|
3
3
|
*
|
|
4
4
|
* The verifier checks that a source run actually exists and matches claims.
|
|
5
|
-
*
|
|
6
|
-
* - stub:
|
|
7
|
-
* - github:
|
|
5
|
+
* Adapters:
|
|
6
|
+
* - stub: always confirms (for tests and local development)
|
|
7
|
+
* - github: confirms via the GitHub Actions API (production)
|
|
8
|
+
* - gitlab: confirms via the GitLab CI API (production)
|
|
9
|
+
*
|
|
10
|
+
* The two real adapters are PEERS — same hardening discipline (per-request
|
|
11
|
+
* timeout, 404-only-false, throw-on-operational, finished-state assertion, and
|
|
12
|
+
* the verify-A-001 commit-binding anti-forgery guard). Production code selects
|
|
13
|
+
* the adapter for a submission via {@link provenanceForProvider}, keyed on
|
|
14
|
+
* `source.provider`, so the provider enum, the run_url parsers
|
|
15
|
+
* (validators/repo-binding.js), and the adapters here stay in lockstep —
|
|
16
|
+
* coverage tests fail CI if any of the three drifts.
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
19
|
/**
|
|
@@ -16,6 +25,72 @@
|
|
|
16
25
|
*/
|
|
17
26
|
export const GITHUB_PROVENANCE_TIMEOUT_MS = 30000;
|
|
18
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Default per-request timeout for the GitLab provenance fetch. Same rationale
|
|
30
|
+
* as the GitHub guard — a hung gitlab.com/api call must not block ingest.
|
|
31
|
+
*/
|
|
32
|
+
export const GITLAB_PROVENANCE_TIMEOUT_MS = 30000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default RETRY budget for a transient provider fault (HTTP 429 rate-limit or a
|
|
36
|
+
* 5xx provider outage). PROACT-VERIFY-001: a single 429/5xx during a momentary
|
|
37
|
+
* blip used to fail the whole submission as an operational incident even though
|
|
38
|
+
* one retry would have confirmed the run. We retry a BOUNDED number of times
|
|
39
|
+
* with exponential backoff (honoring `Retry-After` when the provider sends it),
|
|
40
|
+
* then THROW on exhaustion so a genuinely-down provider still surfaces as an
|
|
41
|
+
* operational signal — never a false 'confirmed'. 404 is NOT retried (the run is
|
|
42
|
+
* genuinely absent, a single immediate rejection).
|
|
43
|
+
*
|
|
44
|
+
* `retries` is the number of ADDITIONAL attempts after the first, so the default
|
|
45
|
+
* 2 means up to 3 total requests. It is an opts field (like `timeoutMs`) so it
|
|
46
|
+
* stays test-injectable and the offline stub path is unaffected.
|
|
47
|
+
*/
|
|
48
|
+
export const PROVENANCE_RETRIES = 2;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Base backoff between retry attempts, in ms. Attempt N waits
|
|
52
|
+
* `PROVENANCE_BACKOFF_MS * 2^(N-1)` (250 → 500 → …), unless the provider sent a
|
|
53
|
+
* `Retry-After` header, which takes precedence. Injectable via opts so tests run
|
|
54
|
+
* without real delay.
|
|
55
|
+
*/
|
|
56
|
+
export const PROVENANCE_BACKOFF_MS = 250;
|
|
57
|
+
|
|
58
|
+
/** HTTP statuses worth retrying: 429 rate-limit + the 5xx provider-outage band. */
|
|
59
|
+
function isRetryableStatus(status) {
|
|
60
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the wait before the next attempt. A `Retry-After` header (delay in
|
|
65
|
+
* seconds, or an HTTP-date) is honored when present and parseable; otherwise
|
|
66
|
+
* fall back to exponential backoff. `attempt` is 1-based (1 = wait before the
|
|
67
|
+
* 2nd request).
|
|
68
|
+
*
|
|
69
|
+
* @param {Response} resp - The non-ok response carrying a possible Retry-After.
|
|
70
|
+
* @param {number} attempt - 1-based retry index.
|
|
71
|
+
* @param {number} backoffMs - Base backoff.
|
|
72
|
+
* @returns {number} Milliseconds to wait (never negative).
|
|
73
|
+
*/
|
|
74
|
+
function nextBackoffMs(resp, attempt, backoffMs) {
|
|
75
|
+
const header = resp?.headers?.get?.('retry-after');
|
|
76
|
+
if (header != null && header !== '') {
|
|
77
|
+
const asSeconds = Number(header);
|
|
78
|
+
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
|
79
|
+
return Math.round(asSeconds * 1000);
|
|
80
|
+
}
|
|
81
|
+
const asDate = Date.parse(header);
|
|
82
|
+
if (!Number.isNaN(asDate)) {
|
|
83
|
+
return Math.max(0, asDate - Date.now());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return backoffMs * 2 ** (attempt - 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Default sleep: a real timer. Injectable via `opts.sleepImpl` for tests. */
|
|
90
|
+
function defaultSleep(ms) {
|
|
91
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
92
|
+
}
|
|
93
|
+
|
|
19
94
|
/**
|
|
20
95
|
* Stub provenance adapter. Always confirms.
|
|
21
96
|
* Use in tests and local development.
|
|
@@ -47,8 +122,20 @@ export const rejectingProvenance = {
|
|
|
47
122
|
export function githubProvenance(token, opts = {}) {
|
|
48
123
|
const timeoutMs = opts.timeoutMs ?? GITHUB_PROVENANCE_TIMEOUT_MS;
|
|
49
124
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
125
|
+
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
126
|
+
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
127
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
50
128
|
return {
|
|
51
|
-
|
|
129
|
+
/**
|
|
130
|
+
* @param {object} source - submission.source (provider-scoped run claim)
|
|
131
|
+
* @param {{ refCommitSha?: string }} [expected] - cross-field invariants the
|
|
132
|
+
* verifier binds at the call site. `refCommitSha` is the PERSISTED
|
|
133
|
+
* `submission.ref.commit_sha` — the commit the record will attest to. When
|
|
134
|
+
* present it is checked MANDATORILY against the confirmed run head, so a
|
|
135
|
+
* submitter cannot point a real run at an arbitrary persisted commit
|
|
136
|
+
* (verify-A-001).
|
|
137
|
+
*/
|
|
138
|
+
async confirm(source, expected = {}) {
|
|
52
139
|
if (source.provider !== 'github') {
|
|
53
140
|
throw new Error(`unsupported provider: ${source.provider}`);
|
|
54
141
|
}
|
|
@@ -72,35 +159,57 @@ export function githubProvenance(token, opts = {}) {
|
|
|
72
159
|
|
|
73
160
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${provider_run_id}`;
|
|
74
161
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// AbortController
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
162
|
+
// PROACT-VERIFY-001: bounded retry over transient provider faults (429
|
|
163
|
+
// rate-limit, 5xx outage). Each attempt carries its own per-request
|
|
164
|
+
// AbortController timeout — without it a hung GitHub API call blocks ingest
|
|
165
|
+
// indefinitely. We retry up to `retries` extra times with exponential
|
|
166
|
+
// backoff (honoring Retry-After), then THROW on exhaustion so a
|
|
167
|
+
// genuinely-down provider surfaces as an operational signal — never a
|
|
168
|
+
// false 'confirmed'. 404 is NOT retried (run genuinely absent). The
|
|
169
|
+
// earlier non-retry behavior (single 429/5xx → immediate throw) failed a
|
|
170
|
+
// submission on a momentary blip that one retry would have confirmed.
|
|
83
171
|
let run;
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
172
|
+
for (let attempt = 0; ; attempt++) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
175
|
+
|
|
176
|
+
let resp;
|
|
177
|
+
try {
|
|
178
|
+
resp = await fetchImpl(apiUrl, {
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${token}`,
|
|
181
|
+
Accept: 'application/vnd.github+json',
|
|
182
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
183
|
+
},
|
|
184
|
+
signal: controller.signal
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
188
|
+
throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
|
|
189
|
+
}
|
|
190
|
+
// Genuine transport error (DNS, connection refused) — no run to
|
|
191
|
+
// confirm. Reserve `return false` for this case (mirrors GitLab).
|
|
192
|
+
return false;
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timer);
|
|
100
195
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
196
|
+
|
|
197
|
+
if (resp.ok) {
|
|
198
|
+
run = await resp.json();
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// verify-A-002: only 404 means the run is genuinely absent — a real
|
|
203
|
+
// submission-bad rejection, never retried. 429/5xx are transient
|
|
204
|
+
// OPERATIONAL signals: retry within budget. 401/403 (expired/insufficient
|
|
205
|
+
// token) are NOT transient — throw immediately. On exhausted retries the
|
|
206
|
+
// last 429/5xx throws so a real outage still surfaces (operational).
|
|
207
|
+
if (resp.status === 404) return false;
|
|
208
|
+
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
209
|
+
await sleep(nextBackoffMs(resp, attempt + 1, backoffMs));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`provenance: GitHub API returned ${resp.status}`);
|
|
104
213
|
}
|
|
105
214
|
|
|
106
215
|
if (run.id !== Number(provider_run_id)) return false;
|
|
@@ -112,6 +221,14 @@ export function githubProvenance(token, opts = {}) {
|
|
|
112
221
|
// underlying CI evidence exists. Rejects 'queued' / 'in_progress' / 'waiting'.
|
|
113
222
|
if (run.status !== 'completed') return false;
|
|
114
223
|
|
|
224
|
+
// verify-A-001: bind the persisted commit to the confirmed run. The record
|
|
225
|
+
// attests to submission.ref.commit_sha (index.js persists submission.ref
|
|
226
|
+
// verbatim), so the run head MUST equal it. Without this, a submitter who
|
|
227
|
+
// owns a real completed run could set ref.commit_sha to any 40-hex sha and
|
|
228
|
+
// earn a provenance_confirmed 'pass' for a commit the run never executed.
|
|
229
|
+
// Mandatory whenever the binding is supplied — not gated on the optional
|
|
230
|
+
// source.commit_sha.
|
|
231
|
+
if (expected.refCommitSha && run.head_sha !== expected.refCommitSha) return false;
|
|
115
232
|
if (source.commit_sha && run.head_sha !== source.commit_sha) return false;
|
|
116
233
|
if (source.repo && run.repository?.full_name !== source.repo) return false;
|
|
117
234
|
|
|
@@ -119,3 +236,186 @@ export function githubProvenance(token, opts = {}) {
|
|
|
119
236
|
}
|
|
120
237
|
};
|
|
121
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Terminal GitLab pipeline/job states. Provenance confirms the pipeline RAN to a
|
|
242
|
+
* terminal state — it is NOT a pass/fail gate (pass/fail is carried by ci_checks
|
|
243
|
+
* and scenario verdicts, exactly as in githubProvenance, which accepts a
|
|
244
|
+
* `completed` run regardless of conclusion). 'success', 'failed', 'canceled',
|
|
245
|
+
* and 'skipped' are terminal; 'running'/'pending'/'created'/'preparing'/
|
|
246
|
+
* 'waiting_for_resource'/'scheduled'/'manual' are not.
|
|
247
|
+
*/
|
|
248
|
+
const GITLAB_FINISHED_STATES = new Set(['success', 'failed', 'canceled', 'cancelled', 'skipped']);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* GitLab CI provenance adapter — peer of {@link githubProvenance}.
|
|
252
|
+
*
|
|
253
|
+
* Confirms a GitLab pipeline (or job) exists and matches the claimed project,
|
|
254
|
+
* commit, and finished state. Mirrors githubProvenance's hardening exactly:
|
|
255
|
+
* - per-request AbortController timeout (throws `provenance: GitLab API timeout`)
|
|
256
|
+
* - returns false ONLY on HTTP 404 (run genuinely absent)
|
|
257
|
+
* - THROWS on 401/403/429/5xx as OPERATIONAL (`provenance: GitLab API returned N`)
|
|
258
|
+
* so parseRejectionReason classifies it operational, not submission-bad
|
|
259
|
+
* - asserts a finished/terminal pipeline state ({@link GITLAB_FINISHED_STATES})
|
|
260
|
+
* - binds the pipeline sha (or job commit.id) to the PERSISTED commit
|
|
261
|
+
* (expected.refCommitSha — the verify-A-001 anti-forgery guard) MANDATORILY,
|
|
262
|
+
* and binds the run_url project path to source.repo
|
|
263
|
+
*
|
|
264
|
+
* The run_url shape decides which endpoint is queried:
|
|
265
|
+
* PIPELINE → GET /api/v4/projects/:id/pipelines/:pipeline_id (commit at .sha)
|
|
266
|
+
* JOB → GET /api/v4/projects/:id/jobs/:job_id (commit at .commit.id)
|
|
267
|
+
* `:id` is the URL-encoded `group/project` (or nested `group/subgroup/project`)
|
|
268
|
+
* path — GitLab accepts the URL-encoded path in place of a numeric project id.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} token - GitLab token with `read_api` scope (sent as PRIVATE-TOKEN).
|
|
271
|
+
* @param {{ timeoutMs?: number, fetchImpl?: typeof fetch, apiBase?: string }} [opts]
|
|
272
|
+
* `apiBase` overrides the API origin for self-hosted GitLab (default
|
|
273
|
+
* 'https://gitlab.com'); tests inject `fetchImpl` so no network is hit.
|
|
274
|
+
* @returns {object} Provenance adapter
|
|
275
|
+
*/
|
|
276
|
+
export function gitlabProvenance(token, opts = {}) {
|
|
277
|
+
const timeoutMs = opts.timeoutMs ?? GITLAB_PROVENANCE_TIMEOUT_MS;
|
|
278
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
279
|
+
const apiBase = (opts.apiBase ?? 'https://gitlab.com').replace(/\/+$/, '');
|
|
280
|
+
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
281
|
+
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
282
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
283
|
+
return {
|
|
284
|
+
/**
|
|
285
|
+
* @param {object} source - submission.source (provider-scoped run claim)
|
|
286
|
+
* @param {{ refCommitSha?: string }} [expected] - see githubProvenance.confirm.
|
|
287
|
+
* `refCommitSha` (the persisted submission.ref.commit_sha) is checked
|
|
288
|
+
* MANDATORILY against the confirmed pipeline/job commit (verify-A-001).
|
|
289
|
+
*/
|
|
290
|
+
async confirm(source, expected = {}) {
|
|
291
|
+
if (source.provider !== 'gitlab') {
|
|
292
|
+
throw new Error(`unsupported provider: ${source.provider}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { provider_run_id, run_url } = source;
|
|
296
|
+
if (!provider_run_id || !run_url) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Decode the project path + run kind + id from the run_url.
|
|
301
|
+
// JOB: https://<host>/<project-path>/-/jobs/<id>
|
|
302
|
+
// PIPELINE: https://<host>/<project-path>/-/pipelines/<id>
|
|
303
|
+
// <project-path> may be a nested namespace (group/subgroup/project).
|
|
304
|
+
const match = run_url.match(
|
|
305
|
+
/^https:\/\/[^/]+\/(.+?)\/-\/(jobs|pipelines)\/(\d+)(?:[/?#].*)?$/
|
|
306
|
+
);
|
|
307
|
+
if (!match) return false;
|
|
308
|
+
|
|
309
|
+
const [, projectPath, runKind, urlRunId] = match;
|
|
310
|
+
|
|
311
|
+
// run id in URL must match the claimed provider_run_id.
|
|
312
|
+
if (urlRunId !== String(provider_run_id)) return false;
|
|
313
|
+
|
|
314
|
+
// Bind the project path to source.repo BEFORE the network call — a forged
|
|
315
|
+
// project claim is a cheap, offline rejection. For GitLab, submission.repo
|
|
316
|
+
// is the full project path (which may contain nested-subgroup slashes).
|
|
317
|
+
if (source.repo && projectPath !== source.repo) return false;
|
|
318
|
+
|
|
319
|
+
const projectId = encodeURIComponent(projectPath);
|
|
320
|
+
const endpoint = runKind === 'jobs'
|
|
321
|
+
? `${apiBase}/api/v4/projects/${projectId}/jobs/${provider_run_id}`
|
|
322
|
+
: `${apiBase}/api/v4/projects/${projectId}/pipelines/${provider_run_id}`;
|
|
323
|
+
|
|
324
|
+
// PROACT-VERIFY-001: bounded retry over transient provider faults — same
|
|
325
|
+
// discipline as githubProvenance. Each attempt carries its own per-request
|
|
326
|
+
// AbortController timeout. 429/5xx retry within budget (exponential backoff,
|
|
327
|
+
// honoring Retry-After), then THROW on exhaustion so a real outage still
|
|
328
|
+
// surfaces as operational — never a false 'confirmed'. 404 is a single
|
|
329
|
+
// immediate rejection; 401/403 throw immediately (not transient).
|
|
330
|
+
let run;
|
|
331
|
+
for (let attempt = 0; ; attempt++) {
|
|
332
|
+
const controller = new AbortController();
|
|
333
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
334
|
+
|
|
335
|
+
let resp;
|
|
336
|
+
try {
|
|
337
|
+
resp = await fetchImpl(endpoint, {
|
|
338
|
+
headers: {
|
|
339
|
+
'PRIVATE-TOKEN': token,
|
|
340
|
+
Accept: 'application/json'
|
|
341
|
+
},
|
|
342
|
+
signal: controller.signal
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
346
|
+
throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
|
|
347
|
+
}
|
|
348
|
+
// Genuine transport error (DNS, connection refused) — no run to confirm.
|
|
349
|
+
return false;
|
|
350
|
+
} finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (resp.ok) {
|
|
355
|
+
run = await resp.json();
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Mirror verify-A-002: 404 = pipeline/job genuinely absent (submission-bad,
|
|
360
|
+
// never retried). 429/5xx = transient operational fault, retry within
|
|
361
|
+
// budget. 401/403 = non-transient operational, throw immediately.
|
|
362
|
+
if (resp.status === 404) return false;
|
|
363
|
+
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
364
|
+
await sleep(nextBackoffMs(resp, attempt + 1, backoffMs));
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
throw new Error(`provenance: GitLab API returned ${resp.status}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// id in the response must match the claimed run id.
|
|
371
|
+
if (run.id !== Number(provider_run_id)) return false;
|
|
372
|
+
|
|
373
|
+
// Contract: confirm the pipeline/job reached a TERMINAL state. Like
|
|
374
|
+
// githubProvenance's `status === 'completed'`, this is not a pass/fail
|
|
375
|
+
// gate — a terminal 'failed'/'canceled' still confirms the run executed.
|
|
376
|
+
if (!GITLAB_FINISHED_STATES.has(run.status)) return false;
|
|
377
|
+
|
|
378
|
+
// The confirmed commit: a pipeline carries it at `.sha`; a job at
|
|
379
|
+
// `.commit.id`. Either must be present for a binding to be possible.
|
|
380
|
+
const runCommit = runKind === 'jobs' ? run.commit?.id : run.sha;
|
|
381
|
+
if (!runCommit) return false;
|
|
382
|
+
|
|
383
|
+
// verify-A-001: bind the PERSISTED commit to the confirmed run. Mandatory
|
|
384
|
+
// whenever the binding is supplied — a submitter who owns a real finished
|
|
385
|
+
// pipeline must not be able to attest an arbitrary commit.
|
|
386
|
+
if (expected.refCommitSha && runCommit !== expected.refCommitSha) return false;
|
|
387
|
+
if (source.commit_sha && runCommit !== source.commit_sha) return false;
|
|
388
|
+
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Provider → provenance-adapter-factory registry, keyed on `source.provider`.
|
|
396
|
+
*
|
|
397
|
+
* Each factory has the signature `(token, opts?) => { confirm(source, expected?) }`.
|
|
398
|
+
* This registry is the lockstep partner of `RUN_URL_PARSERS` in
|
|
399
|
+
* validators/repo-binding.js: a provider in the `source.provider` schema enum
|
|
400
|
+
* MUST have BOTH a run_url parser (so the anti-forgery repo-binding decodes it)
|
|
401
|
+
* AND a provenance adapter (so its runs are confirmable). Two coverage tests —
|
|
402
|
+
* validators/repo-binding.test.js and validators/provenance-registry.test.js —
|
|
403
|
+
* read the actual enum and fail CI if either side is missing for a provider.
|
|
404
|
+
*
|
|
405
|
+
* @type {Record<string, (token: string, opts?: object) => { confirm: Function }>}
|
|
406
|
+
*/
|
|
407
|
+
export const PROVENANCE_ADAPTERS = {
|
|
408
|
+
github: githubProvenance,
|
|
409
|
+
gitlab: gitlabProvenance,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Resolve the provenance-adapter factory for a `source.provider`.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
|
|
416
|
+
* @returns {((token: string, opts?: object) => { confirm: Function }) | null}
|
|
417
|
+
* The adapter factory, or `null` when the provider has no registered adapter.
|
|
418
|
+
*/
|
|
419
|
+
export function provenanceForProvider(provider) {
|
|
420
|
+
return PROVENANCE_ADAPTERS[provider] ?? null;
|
|
421
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-field repo-binding parsers (verify-B-001).
|
|
3
|
+
*
|
|
4
|
+
* The anti-forgery guard in verify/index.js binds `submission.repo` to the
|
|
5
|
+
* owner/repo encoded in `submission.source.run_url`, so a submitter cannot
|
|
6
|
+
* claim `repo='victim/repo'` while supplying a real run_url from their own
|
|
7
|
+
* repo (the verify-A-001 vector). That guard only works if it can decode the
|
|
8
|
+
* run_url for the submission's PROVIDER.
|
|
9
|
+
*
|
|
10
|
+
* Pre-fix the GitHub run_url regex was inlined in index.js behind `if (m)`.
|
|
11
|
+
* A second provider in the `source.provider` enum would fall through `m ===
|
|
12
|
+
* null` and the binding would SILENTLY no-op — reopening the forgery vector
|
|
13
|
+
* for the new provider with no error and no test failure.
|
|
14
|
+
*
|
|
15
|
+
* The fix keys the parsers by provider. `RUN_URL_PARSERS` MUST stay in lockstep
|
|
16
|
+
* with the `source.provider` enum in dogfood-record-submission.schema.json —
|
|
17
|
+
* a guard-coverage test (repo-binding.test.js) asserts exactly that, so adding
|
|
18
|
+
* a provider to the schema without adding a parser here fails CI instead of
|
|
19
|
+
* silently disabling the binding for the new provider.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} RunUrlRepo
|
|
24
|
+
* @property {string} owner Repository owner/org.
|
|
25
|
+
* @property {string} repo Repository name.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-provider run_url → { owner, repo } parser.
|
|
30
|
+
*
|
|
31
|
+
* Each parser returns `null` when the URL does not match the provider's run_url
|
|
32
|
+
* shape (malformed/foreign URL). A malformed URL is NOT a binding failure —
|
|
33
|
+
* the schema validator already rejects a malformed source.run_url — so the
|
|
34
|
+
* guard treats a `null` parse as "nothing to bind against" and lets the schema
|
|
35
|
+
* layer own the rejection.
|
|
36
|
+
*
|
|
37
|
+
* @type {Record<string, (runUrl: string) => RunUrlRepo | null>}
|
|
38
|
+
*/
|
|
39
|
+
export const RUN_URL_PARSERS = {
|
|
40
|
+
// Format: https://github.com/{owner}/{repo}/actions/runs/{id}
|
|
41
|
+
github(runUrl) {
|
|
42
|
+
const m = runUrl.match(
|
|
43
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
|
|
44
|
+
);
|
|
45
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// GitLab run_url shapes (gitlab.com AND self-hosted hosts):
|
|
49
|
+
// JOB: https://<host>/<namespace>/<project>/-/jobs/<id>
|
|
50
|
+
// PIPELINE: https://<host>/<namespace>/<project>/-/pipelines/<id>
|
|
51
|
+
// The project path is everything between the host and the `/-/` run segment.
|
|
52
|
+
//
|
|
53
|
+
// NESTED-SUBGROUP MAPPING (load-bearing decision, kept consistent with how
|
|
54
|
+
// submission.repo is expressed for GitLab): GitLab namespaces can nest —
|
|
55
|
+
// `group/subgroup/project`. We map owner = the FULL namespace (everything
|
|
56
|
+
// before the last path segment, slashes preserved) and repo = the LAST segment
|
|
57
|
+
// (the project). So `${owner}/${repo}` reconstructs the full project path. For
|
|
58
|
+
// a flat `group/project` this degenerates to owner='group', repo='project'
|
|
59
|
+
// (same shape as GitHub). The repo-binding guard then compares
|
|
60
|
+
// `${owner}/${repo}` against submission.repo, so for GitLab submission.repo is
|
|
61
|
+
// the FULL project path — which may contain more than one slash for nested
|
|
62
|
+
// subgroups, unlike GitHub's strict two-segment org/repo.
|
|
63
|
+
//
|
|
64
|
+
// A single-segment path (no namespace + project, just `<project>/-/jobs/<id>`)
|
|
65
|
+
// returns null — it cannot be split into owner + repo and is not a valid
|
|
66
|
+
// GitLab project URL.
|
|
67
|
+
gitlab(runUrl) {
|
|
68
|
+
const m = runUrl.match(
|
|
69
|
+
/^https:\/\/[^/]+\/(.+?)\/-\/(?:jobs|pipelines)\/\d+(?:[/?#].*)?$/
|
|
70
|
+
);
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
const segments = m[1].split('/');
|
|
73
|
+
if (segments.length < 2) return null; // need at least namespace + project
|
|
74
|
+
const repo = segments.pop();
|
|
75
|
+
const owner = segments.join('/');
|
|
76
|
+
return { owner, repo };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decode the owner/repo a run_url attests to, for the given provider.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} provider The `source.provider` token (e.g. 'github').
|
|
84
|
+
* @param {string} runUrl The `source.run_url`.
|
|
85
|
+
* @returns {RunUrlRepo | null} `{ owner, repo }`, or `null` when the provider
|
|
86
|
+
* has no parser or the URL does not match the provider's shape.
|
|
87
|
+
*/
|
|
88
|
+
export function parseRunUrlRepo(provider, runUrl) {
|
|
89
|
+
const parser = RUN_URL_PARSERS[provider];
|
|
90
|
+
if (!parser) return null;
|
|
91
|
+
return parser(runUrl);
|
|
92
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repo-binding.test.js — verify-B-001 (Stage C, MED)
|
|
3
|
+
*
|
|
4
|
+
* The cross-field anti-forgery guard in verify/index.js binds submission.repo
|
|
5
|
+
* to the owner/repo decoded from source.run_url. That decode is provider-keyed
|
|
6
|
+
* (RUN_URL_PARSERS in repo-binding.js). The forgery vector (verify-A-001)
|
|
7
|
+
* reopens silently if a provider is added to the submission schema's
|
|
8
|
+
* `source.provider` enum without a corresponding parser branch: the guard would
|
|
9
|
+
* see `parseRunUrlRepo(provider, url) === null`, skip the comparison, and accept
|
|
10
|
+
* a forged repo with no error and no other test failure.
|
|
11
|
+
*
|
|
12
|
+
* The coverage test below is the tripwire: it reads the ACTUAL `source.provider`
|
|
13
|
+
* enum from the canonical submission schema and asserts every member has a
|
|
14
|
+
* parser. Add a provider to the schema and forget the parser → this test goes
|
|
15
|
+
* red in CI before the binding can ship disabled.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
import { RUN_URL_PARSERS, parseRunUrlRepo } from './repo-binding.js';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const schemaPath = require.resolve(
|
|
27
|
+
'@dogfood-lab/schemas/json/dogfood-record-submission.schema.json'
|
|
28
|
+
);
|
|
29
|
+
const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
30
|
+
const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
|
|
31
|
+
|
|
32
|
+
describe('repo-binding guard coverage (verify-B-001)', () => {
|
|
33
|
+
it('exposes the provider enum it is meant to cover (sanity)', () => {
|
|
34
|
+
assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
|
|
35
|
+
assert.ok(PROVIDER_ENUM.includes('github'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('has a run_url parser for EVERY registered source.provider', () => {
|
|
39
|
+
const missing = PROVIDER_ENUM.filter(p => typeof RUN_URL_PARSERS[p] !== 'function');
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
missing,
|
|
42
|
+
[],
|
|
43
|
+
`source.provider enum members without a RUN_URL_PARSERS branch: ${missing.join(', ')}. ` +
|
|
44
|
+
'Add a parser in validators/repo-binding.js or the anti-forgery repo binding ' +
|
|
45
|
+
'silently no-ops for these providers (verify-A-001 / verify-B-001).'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('github parser', () => {
|
|
50
|
+
it('decodes owner/repo from a well-formed run_url', () => {
|
|
51
|
+
const bound = parseRunUrlRepo(
|
|
52
|
+
'github',
|
|
53
|
+
'https://github.com/acme/widget/actions/runs/12345'
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null for a foreign/malformed run_url', () => {
|
|
59
|
+
assert.equal(parseRunUrlRepo('github', 'https://example.com/x'), null);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('gitlab parser', () => {
|
|
64
|
+
it('decodes owner/repo from a JOB run_url', () => {
|
|
65
|
+
const bound = parseRunUrlRepo(
|
|
66
|
+
'gitlab',
|
|
67
|
+
'https://gitlab.com/acme/widget/-/jobs/987654'
|
|
68
|
+
);
|
|
69
|
+
assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('decodes owner/repo from a PIPELINE run_url', () => {
|
|
73
|
+
const bound = parseRunUrlRepo(
|
|
74
|
+
'gitlab',
|
|
75
|
+
'https://gitlab.com/acme/widget/-/pipelines/424242'
|
|
76
|
+
);
|
|
77
|
+
assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('maps nested subgroups: owner = full namespace, repo = last segment', () => {
|
|
81
|
+
// GitLab supports nested subgroups (group/subgroup/project). The mapping
|
|
82
|
+
// decision (documented in repo-binding.js): owner = everything before the
|
|
83
|
+
// LAST path segment (the full namespace, slashes preserved), repo = the
|
|
84
|
+
// last segment. Reconstructing `${owner}/${repo}` yields the full project
|
|
85
|
+
// path, which is what submission.repo carries for GitLab.
|
|
86
|
+
const bound = parseRunUrlRepo(
|
|
87
|
+
'gitlab',
|
|
88
|
+
'https://gitlab.com/acme/team-a/widget/-/pipelines/1'
|
|
89
|
+
);
|
|
90
|
+
assert.deepEqual(bound, { owner: 'acme/team-a', repo: 'widget' });
|
|
91
|
+
assert.equal(`${bound.owner}/${bound.repo}`, 'acme/team-a/widget');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('parses self-hosted GitLab hosts (owner/repo = path before /-/)', () => {
|
|
95
|
+
const bound = parseRunUrlRepo(
|
|
96
|
+
'gitlab',
|
|
97
|
+
'https://gitlab.example.com/acme/widget/-/jobs/55'
|
|
98
|
+
);
|
|
99
|
+
assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns null for a foreign/malformed run_url', () => {
|
|
103
|
+
assert.equal(parseRunUrlRepo('gitlab', 'https://example.com/x'), null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null when the URL has no /-/ run segment', () => {
|
|
107
|
+
assert.equal(parseRunUrlRepo('gitlab', 'https://gitlab.com/acme/widget'), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns null for a single-segment path (no namespace + project)', () => {
|
|
111
|
+
assert.equal(parseRunUrlRepo('gitlab', 'https://gitlab.com/widget/-/jobs/1'), null);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns null for a provider with no parser (no throw)', () => {
|
|
116
|
+
assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
|
|
117
|
+
});
|
|
118
|
+
});
|