@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.
@@ -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
+ });
@@ -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
- * Two adapters:
6
- * - stub: always confirms (for tests and local development)
7
- * - github: confirms via GitHub Actions API (for production)
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
- async confirm(source) {
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
- // Per-request timeout. Without this, a hung GitHub API call (rate-limit
76
- // throttle, regional outage, slow connection) blocks ingest indefinitely.
77
- // AbortController fires AbortError on timeout we re-throw with a clear
78
- // message so the verifier records it in rejection_reasons instead of
79
- // silently treating it as 'provenance returned false.'
80
- const controller = new AbortController();
81
- const timer = setTimeout(() => controller.abort(), timeoutMs);
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
- try {
85
- const resp = await fetchImpl(apiUrl, {
86
- headers: {
87
- Authorization: `Bearer ${token}`,
88
- Accept: 'application/vnd.github+json',
89
- 'X-GitHub-Api-Version': '2022-11-28'
90
- },
91
- signal: controller.signal
92
- });
93
-
94
- if (!resp.ok) return false;
95
-
96
- run = await resp.json();
97
- } catch (err) {
98
- if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
99
- throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
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
- return false;
102
- } finally {
103
- clearTimeout(timer);
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
+ });