@dogfood-lab/verify 1.3.2 → 1.5.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 +46 -25
- package/cli.js +444 -0
- package/index.js +68 -10
- package/package.json +8 -1
- package/parse-rejection.js +136 -0
- package/validators/policy.js +9 -1
- package/validators/provenance-gitlab.test.js +300 -0
- package/validators/provenance-registry.test.js +56 -0
- package/validators/provenance.js +232 -5
- package/validators/repo-binding.js +92 -0
- package/validators/repo-binding.test.js +118 -0
- package/validators/schema-version.js +111 -0
- package/validators/schema.js +8 -17
- package/validators/steps.js +10 -2
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,12 @@
|
|
|
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
|
+
|
|
19
34
|
/**
|
|
20
35
|
* Stub provenance adapter. Always confirms.
|
|
21
36
|
* Use in tests and local development.
|
|
@@ -48,7 +63,16 @@ export function githubProvenance(token, opts = {}) {
|
|
|
48
63
|
const timeoutMs = opts.timeoutMs ?? GITHUB_PROVENANCE_TIMEOUT_MS;
|
|
49
64
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
50
65
|
return {
|
|
51
|
-
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} source - submission.source (provider-scoped run claim)
|
|
68
|
+
* @param {{ refCommitSha?: string }} [expected] - cross-field invariants the
|
|
69
|
+
* verifier binds at the call site. `refCommitSha` is the PERSISTED
|
|
70
|
+
* `submission.ref.commit_sha` — the commit the record will attest to. When
|
|
71
|
+
* present it is checked MANDATORILY against the confirmed run head, so a
|
|
72
|
+
* submitter cannot point a real run at an arbitrary persisted commit
|
|
73
|
+
* (verify-A-001).
|
|
74
|
+
*/
|
|
75
|
+
async confirm(source, expected = {}) {
|
|
52
76
|
if (source.provider !== 'github') {
|
|
53
77
|
throw new Error(`unsupported provider: ${source.provider}`);
|
|
54
78
|
}
|
|
@@ -91,13 +115,32 @@ export function githubProvenance(token, opts = {}) {
|
|
|
91
115
|
signal: controller.signal
|
|
92
116
|
});
|
|
93
117
|
|
|
94
|
-
if (!resp.ok)
|
|
118
|
+
if (!resp.ok) {
|
|
119
|
+
// verify-A-002: only 404 means the run is genuinely absent — a real
|
|
120
|
+
// submission-bad rejection. Every other non-2xx is an OPERATIONAL
|
|
121
|
+
// signal (401/403 expired/insufficient token, 429 rate limit, 5xx
|
|
122
|
+
// GitHub outage). Collapsing those into `return false` made the
|
|
123
|
+
// verifier record 'source run could not be confirmed' and routing
|
|
124
|
+
// bounced an ops incident to submitters. Throw so the existing catch
|
|
125
|
+
// in index.js records it as a provenance verification failure
|
|
126
|
+
// (operational), mirroring the timeout fix above.
|
|
127
|
+
if (resp.status === 404) return false;
|
|
128
|
+
throw new Error(`provenance: GitHub API returned ${resp.status}`);
|
|
129
|
+
}
|
|
95
130
|
|
|
96
131
|
run = await resp.json();
|
|
97
132
|
} catch (err) {
|
|
98
133
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
99
134
|
throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
|
|
100
135
|
}
|
|
136
|
+
// verify-A-002: operational signals we raised ourselves (non-2xx HTTP)
|
|
137
|
+
// already carry the 'provenance:' prefix — re-throw them so they reach
|
|
138
|
+
// index.js as a verification failure rather than being swallowed into
|
|
139
|
+
// 'return false'. Reserve `return false` for genuine transport errors
|
|
140
|
+
// (DNS, connection refused) where there is no run to confirm.
|
|
141
|
+
if (err && typeof err.message === 'string' && err.message.startsWith('provenance:')) {
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
101
144
|
return false;
|
|
102
145
|
} finally {
|
|
103
146
|
clearTimeout(timer);
|
|
@@ -112,6 +155,14 @@ export function githubProvenance(token, opts = {}) {
|
|
|
112
155
|
// underlying CI evidence exists. Rejects 'queued' / 'in_progress' / 'waiting'.
|
|
113
156
|
if (run.status !== 'completed') return false;
|
|
114
157
|
|
|
158
|
+
// verify-A-001: bind the persisted commit to the confirmed run. The record
|
|
159
|
+
// attests to submission.ref.commit_sha (index.js persists submission.ref
|
|
160
|
+
// verbatim), so the run head MUST equal it. Without this, a submitter who
|
|
161
|
+
// owns a real completed run could set ref.commit_sha to any 40-hex sha and
|
|
162
|
+
// earn a provenance_confirmed 'pass' for a commit the run never executed.
|
|
163
|
+
// Mandatory whenever the binding is supplied — not gated on the optional
|
|
164
|
+
// source.commit_sha.
|
|
165
|
+
if (expected.refCommitSha && run.head_sha !== expected.refCommitSha) return false;
|
|
115
166
|
if (source.commit_sha && run.head_sha !== source.commit_sha) return false;
|
|
116
167
|
if (source.repo && run.repository?.full_name !== source.repo) return false;
|
|
117
168
|
|
|
@@ -119,3 +170,179 @@ export function githubProvenance(token, opts = {}) {
|
|
|
119
170
|
}
|
|
120
171
|
};
|
|
121
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Terminal GitLab pipeline/job states. Provenance confirms the pipeline RAN to a
|
|
176
|
+
* terminal state — it is NOT a pass/fail gate (pass/fail is carried by ci_checks
|
|
177
|
+
* and scenario verdicts, exactly as in githubProvenance, which accepts a
|
|
178
|
+
* `completed` run regardless of conclusion). 'success', 'failed', 'canceled',
|
|
179
|
+
* and 'skipped' are terminal; 'running'/'pending'/'created'/'preparing'/
|
|
180
|
+
* 'waiting_for_resource'/'scheduled'/'manual' are not.
|
|
181
|
+
*/
|
|
182
|
+
const GITLAB_FINISHED_STATES = new Set(['success', 'failed', 'canceled', 'cancelled', 'skipped']);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* GitLab CI provenance adapter — peer of {@link githubProvenance}.
|
|
186
|
+
*
|
|
187
|
+
* Confirms a GitLab pipeline (or job) exists and matches the claimed project,
|
|
188
|
+
* commit, and finished state. Mirrors githubProvenance's hardening exactly:
|
|
189
|
+
* - per-request AbortController timeout (throws `provenance: GitLab API timeout`)
|
|
190
|
+
* - returns false ONLY on HTTP 404 (run genuinely absent)
|
|
191
|
+
* - THROWS on 401/403/429/5xx as OPERATIONAL (`provenance: GitLab API returned N`)
|
|
192
|
+
* so parseRejectionReason classifies it operational, not submission-bad
|
|
193
|
+
* - asserts a finished/terminal pipeline state ({@link GITLAB_FINISHED_STATES})
|
|
194
|
+
* - binds the pipeline sha (or job commit.id) to the PERSISTED commit
|
|
195
|
+
* (expected.refCommitSha — the verify-A-001 anti-forgery guard) MANDATORILY,
|
|
196
|
+
* and binds the run_url project path to source.repo
|
|
197
|
+
*
|
|
198
|
+
* The run_url shape decides which endpoint is queried:
|
|
199
|
+
* PIPELINE → GET /api/v4/projects/:id/pipelines/:pipeline_id (commit at .sha)
|
|
200
|
+
* JOB → GET /api/v4/projects/:id/jobs/:job_id (commit at .commit.id)
|
|
201
|
+
* `:id` is the URL-encoded `group/project` (or nested `group/subgroup/project`)
|
|
202
|
+
* path — GitLab accepts the URL-encoded path in place of a numeric project id.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} token - GitLab token with `read_api` scope (sent as PRIVATE-TOKEN).
|
|
205
|
+
* @param {{ timeoutMs?: number, fetchImpl?: typeof fetch, apiBase?: string }} [opts]
|
|
206
|
+
* `apiBase` overrides the API origin for self-hosted GitLab (default
|
|
207
|
+
* 'https://gitlab.com'); tests inject `fetchImpl` so no network is hit.
|
|
208
|
+
* @returns {object} Provenance adapter
|
|
209
|
+
*/
|
|
210
|
+
export function gitlabProvenance(token, opts = {}) {
|
|
211
|
+
const timeoutMs = opts.timeoutMs ?? GITLAB_PROVENANCE_TIMEOUT_MS;
|
|
212
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
213
|
+
const apiBase = (opts.apiBase ?? 'https://gitlab.com').replace(/\/+$/, '');
|
|
214
|
+
return {
|
|
215
|
+
/**
|
|
216
|
+
* @param {object} source - submission.source (provider-scoped run claim)
|
|
217
|
+
* @param {{ refCommitSha?: string }} [expected] - see githubProvenance.confirm.
|
|
218
|
+
* `refCommitSha` (the persisted submission.ref.commit_sha) is checked
|
|
219
|
+
* MANDATORILY against the confirmed pipeline/job commit (verify-A-001).
|
|
220
|
+
*/
|
|
221
|
+
async confirm(source, expected = {}) {
|
|
222
|
+
if (source.provider !== 'gitlab') {
|
|
223
|
+
throw new Error(`unsupported provider: ${source.provider}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { provider_run_id, run_url } = source;
|
|
227
|
+
if (!provider_run_id || !run_url) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Decode the project path + run kind + id from the run_url.
|
|
232
|
+
// JOB: https://<host>/<project-path>/-/jobs/<id>
|
|
233
|
+
// PIPELINE: https://<host>/<project-path>/-/pipelines/<id>
|
|
234
|
+
// <project-path> may be a nested namespace (group/subgroup/project).
|
|
235
|
+
const match = run_url.match(
|
|
236
|
+
/^https:\/\/[^/]+\/(.+?)\/-\/(jobs|pipelines)\/(\d+)(?:[/?#].*)?$/
|
|
237
|
+
);
|
|
238
|
+
if (!match) return false;
|
|
239
|
+
|
|
240
|
+
const [, projectPath, runKind, urlRunId] = match;
|
|
241
|
+
|
|
242
|
+
// run id in URL must match the claimed provider_run_id.
|
|
243
|
+
if (urlRunId !== String(provider_run_id)) return false;
|
|
244
|
+
|
|
245
|
+
// Bind the project path to source.repo BEFORE the network call — a forged
|
|
246
|
+
// project claim is a cheap, offline rejection. For GitLab, submission.repo
|
|
247
|
+
// is the full project path (which may contain nested-subgroup slashes).
|
|
248
|
+
if (source.repo && projectPath !== source.repo) return false;
|
|
249
|
+
|
|
250
|
+
const projectId = encodeURIComponent(projectPath);
|
|
251
|
+
const endpoint = runKind === 'jobs'
|
|
252
|
+
? `${apiBase}/api/v4/projects/${projectId}/jobs/${provider_run_id}`
|
|
253
|
+
: `${apiBase}/api/v4/projects/${projectId}/pipelines/${provider_run_id}`;
|
|
254
|
+
|
|
255
|
+
// Per-request timeout — identical discipline to githubProvenance. Without
|
|
256
|
+
// it a hung gitlab.com/api call blocks ingest until the surrounding runner
|
|
257
|
+
// times out. AbortError → re-thrown with a clear 'provenance:' message so
|
|
258
|
+
// the verifier records it instead of silently returning false.
|
|
259
|
+
const controller = new AbortController();
|
|
260
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
261
|
+
|
|
262
|
+
let run;
|
|
263
|
+
try {
|
|
264
|
+
const resp = await fetchImpl(endpoint, {
|
|
265
|
+
headers: {
|
|
266
|
+
'PRIVATE-TOKEN': token,
|
|
267
|
+
Accept: 'application/json'
|
|
268
|
+
},
|
|
269
|
+
signal: controller.signal
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!resp.ok) {
|
|
273
|
+
// Mirror verify-A-002: only 404 means the pipeline/job is genuinely
|
|
274
|
+
// absent (submission-bad). 401/403/429/5xx are OPERATIONAL — throw so
|
|
275
|
+
// the catch in index.js records a 'provenance:' verification failure
|
|
276
|
+
// (paged to ops) instead of bouncing an outage to the submitter.
|
|
277
|
+
if (resp.status === 404) return false;
|
|
278
|
+
throw new Error(`provenance: GitLab API returned ${resp.status}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
run = await resp.json();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
284
|
+
throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
|
|
285
|
+
}
|
|
286
|
+
// Operational signals we raised ourselves already carry the
|
|
287
|
+
// 'provenance:' prefix — re-throw. Reserve `return false` for genuine
|
|
288
|
+
// transport errors (DNS, connection refused) where there is no run.
|
|
289
|
+
if (err && typeof err.message === 'string' && err.message.startsWith('provenance:')) {
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// id in the response must match the claimed run id.
|
|
298
|
+
if (run.id !== Number(provider_run_id)) return false;
|
|
299
|
+
|
|
300
|
+
// Contract: confirm the pipeline/job reached a TERMINAL state. Like
|
|
301
|
+
// githubProvenance's `status === 'completed'`, this is not a pass/fail
|
|
302
|
+
// gate — a terminal 'failed'/'canceled' still confirms the run executed.
|
|
303
|
+
if (!GITLAB_FINISHED_STATES.has(run.status)) return false;
|
|
304
|
+
|
|
305
|
+
// The confirmed commit: a pipeline carries it at `.sha`; a job at
|
|
306
|
+
// `.commit.id`. Either must be present for a binding to be possible.
|
|
307
|
+
const runCommit = runKind === 'jobs' ? run.commit?.id : run.sha;
|
|
308
|
+
if (!runCommit) return false;
|
|
309
|
+
|
|
310
|
+
// verify-A-001: bind the PERSISTED commit to the confirmed run. Mandatory
|
|
311
|
+
// whenever the binding is supplied — a submitter who owns a real finished
|
|
312
|
+
// pipeline must not be able to attest an arbitrary commit.
|
|
313
|
+
if (expected.refCommitSha && runCommit !== expected.refCommitSha) return false;
|
|
314
|
+
if (source.commit_sha && runCommit !== source.commit_sha) return false;
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Provider → provenance-adapter-factory registry, keyed on `source.provider`.
|
|
323
|
+
*
|
|
324
|
+
* Each factory has the signature `(token, opts?) => { confirm(source, expected?) }`.
|
|
325
|
+
* This registry is the lockstep partner of `RUN_URL_PARSERS` in
|
|
326
|
+
* validators/repo-binding.js: a provider in the `source.provider` schema enum
|
|
327
|
+
* MUST have BOTH a run_url parser (so the anti-forgery repo-binding decodes it)
|
|
328
|
+
* AND a provenance adapter (so its runs are confirmable). Two coverage tests —
|
|
329
|
+
* validators/repo-binding.test.js and validators/provenance-registry.test.js —
|
|
330
|
+
* read the actual enum and fail CI if either side is missing for a provider.
|
|
331
|
+
*
|
|
332
|
+
* @type {Record<string, (token: string, opts?: object) => { confirm: Function }>}
|
|
333
|
+
*/
|
|
334
|
+
export const PROVENANCE_ADAPTERS = {
|
|
335
|
+
github: githubProvenance,
|
|
336
|
+
gitlab: gitlabProvenance,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the provenance-adapter factory for a `source.provider`.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
|
|
343
|
+
* @returns {((token: string, opts?: object) => { confirm: Function }) | null}
|
|
344
|
+
* The adapter factory, or `null` when the provider has no registered adapter.
|
|
345
|
+
*/
|
|
346
|
+
export function provenanceForProvider(provider) {
|
|
347
|
+
return PROVENANCE_ADAPTERS[provider] ?? null;
|
|
348
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema_version VALUE gate — validators/schema-version.js
|
|
3
|
+
*
|
|
4
|
+
* F1-CONTRACTS-001 (Wave 2, HIGH). The contract JSON schemas gate
|
|
5
|
+
* `schema_version` by PATTERN only (`^\d+\.\d+\.\d+$`). Nothing compared the
|
|
6
|
+
* declared value against the set this build actually supports, so a
|
|
7
|
+
* submission declaring `schema_version: '2.0.0'` (or `99.0.0`) validated
|
|
8
|
+
* clean against the live 1.x schema whenever its shape happened to fit — a
|
|
9
|
+
* genuinely-incompatible future major was silently mis-validated instead of
|
|
10
|
+
* cleanly refused.
|
|
11
|
+
*
|
|
12
|
+
* This validator compares a payload's MAJOR against
|
|
13
|
+
* SUPPORTED_SCHEMA_VERSIONS (the single source of truth imported from
|
|
14
|
+
* `@dogfood-lab/schemas` — NOT a hand-copied literal) for the named contract
|
|
15
|
+
* and returns a TYPED rejection-reason string:
|
|
16
|
+
*
|
|
17
|
+
* - major > maxMajor → `CONTRACT_SCHEMA_TOO_NEW: ...` (operator must upgrade
|
|
18
|
+
* testing-os — this build does not understand the future contract).
|
|
19
|
+
* - major < minMajor → `CONTRACT_SCHEMA_TOO_OLD: ...` (submitter must
|
|
20
|
+
* re-emit against the current contract).
|
|
21
|
+
* - in range → no reason (PASS; a patch/minor delta inside the range
|
|
22
|
+
* is accepted — do NOT reject on a non-major bump).
|
|
23
|
+
* - malformed / absent version → no reason (the JSON-schema PATTERN gate
|
|
24
|
+
* already owns shape; this validator only owns the VALUE comparison and
|
|
25
|
+
* stays silent rather than double-reporting a shape fault).
|
|
26
|
+
*
|
|
27
|
+
* Unlike the `schema`/`policy`/`steps` validators (whose caller prepends a
|
|
28
|
+
* single fixed prefix), the TOO_NEW vs TOO_OLD discriminant is known HERE, at
|
|
29
|
+
* the branch that fired — so this validator emits the FULL prefixed reason
|
|
30
|
+
* string. `index.js` pushes it verbatim into `verification.rejection_reasons`
|
|
31
|
+
* via the SAME `runValidator` seam (a thrown unknown-contract fault still
|
|
32
|
+
* surfaces as `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION`). The
|
|
33
|
+
* `CONTRACT_SCHEMA_*` prefixes join the documented prefix taxonomy (see
|
|
34
|
+
* verify/README.md → "Error shape").
|
|
35
|
+
*
|
|
36
|
+
* Return contract: `{ valid: boolean, errors: string[] }` — `errors` holds
|
|
37
|
+
* the already-prefixed `CONTRACT_SCHEMA_*: ...` reason(s).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the MAJOR component of a semver-ish string. Returns null when the
|
|
44
|
+
* value is not a `\d+\.\d+\.\d+` triple — the shape gate owns that fault.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} version
|
|
47
|
+
* @returns {number | null}
|
|
48
|
+
*/
|
|
49
|
+
function parseMajor(version) {
|
|
50
|
+
if (typeof version !== 'string') return null;
|
|
51
|
+
const m = version.match(/^(\d+)\.\d+\.\d+$/);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
return Number(m[1]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gate a payload's declared `schema_version` against the supported MAJOR
|
|
58
|
+
* range for a named contract.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} payload - Payload carrying a `schema_version` field.
|
|
61
|
+
* @param {string} [contract='recordSubmission'] - SUPPORTED_SCHEMA_VERSIONS key.
|
|
62
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
63
|
+
*/
|
|
64
|
+
export function validateSchemaVersion(payload, contract = 'recordSubmission') {
|
|
65
|
+
// Only gate payloads that actually declare a schema_version. A null/absent
|
|
66
|
+
// value is the shape gate's concern, not the value gate's.
|
|
67
|
+
if (!payload || typeof payload !== 'object') {
|
|
68
|
+
return { valid: true, errors: [] };
|
|
69
|
+
}
|
|
70
|
+
const declared = payload.schema_version;
|
|
71
|
+
if (declared === undefined || declared === null) {
|
|
72
|
+
return { valid: true, errors: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const supported = SUPPORTED_SCHEMA_VERSIONS[contract];
|
|
76
|
+
if (!supported) {
|
|
77
|
+
// Unknown contract key is a programmer error in the call site, not a
|
|
78
|
+
// submission fault — throw so the runValidator seam surfaces it as a
|
|
79
|
+
// VALIDATOR_FAULT_* operational incident rather than a submission reason.
|
|
80
|
+
throw new Error(`validateSchemaVersion: unknown contract "${contract}"`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const major = parseMajor(declared);
|
|
84
|
+
if (major === null) {
|
|
85
|
+
// Malformed shape — defer to the JSON-schema PATTERN gate; do not
|
|
86
|
+
// double-report.
|
|
87
|
+
return { valid: true, errors: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (major > supported.maxMajor) {
|
|
91
|
+
return {
|
|
92
|
+
valid: false,
|
|
93
|
+
errors: [
|
|
94
|
+
`CONTRACT_SCHEMA_TOO_NEW: ${contract} schema v${declared} but this build supports ` +
|
|
95
|
+
`v${supported.current} (major ${supported.minMajor}–${supported.maxMajor}) — upgrade testing-os`,
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (major < supported.minMajor) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
errors: [
|
|
104
|
+
`CONTRACT_SCHEMA_TOO_OLD: ${contract} schema v${declared} is below the supported floor ` +
|
|
105
|
+
`v${supported.current} (major ${supported.minMajor}–${supported.maxMajor}) — re-emit against the current contract`,
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { valid: true, errors: [] };
|
|
111
|
+
}
|
package/validators/schema.js
CHANGED
|
@@ -27,23 +27,14 @@ import { validatePayload } from '@dogfood-lab/schemas';
|
|
|
27
27
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
28
28
|
*/
|
|
29
29
|
export function validateSubmissionSchema(submission) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// routed through `VALIDATOR_FAULT_*` — `getValidator` returned a plain
|
|
39
|
-
// object and the call site special-cased its `__loadError` key. The
|
|
40
|
-
// post-H3 path is cleaner: a thrown error here propagates up to
|
|
41
|
-
// runValidator which already wraps thrown validators in
|
|
42
|
-
// `VALIDATOR_FAULT_SCHEMA` (D1B-003 humanization). Same operator-
|
|
43
|
-
// facing prefix, just routed through the lawful seam instead of a
|
|
44
|
-
// sentinel. No catch — propagate.
|
|
45
|
-
throw e;
|
|
46
|
-
}
|
|
30
|
+
// The canonical compile path can throw at compileSchema time if the schema
|
|
31
|
+
// file is unreadable or malformed (Ajv compile fault). We deliberately do
|
|
32
|
+
// NOT catch it here: a thrown error propagates up to runValidator in
|
|
33
|
+
// verify/index.js, which wraps thrown validators in `VALIDATOR_FAULT_SCHEMA`
|
|
34
|
+
// (D1B-003 humanization). Pre-H3 this fault was surfaced via a `{ __loadError }`
|
|
35
|
+
// sentinel the call site special-cased; routing the throw through the lawful
|
|
36
|
+
// runValidator seam yields the same operator-facing prefix without the sentinel.
|
|
37
|
+
const result = validatePayload('recordSubmission', submission);
|
|
47
38
|
|
|
48
39
|
if (result.valid) {
|
|
49
40
|
return { valid: true, errors: [] };
|
package/validators/steps.js
CHANGED
|
@@ -25,7 +25,11 @@ export function validateStepResults(scenarioResult) {
|
|
|
25
25
|
return errors;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Must match the step_results[].status enum in BOTH dogfood-record-submission.schema.json
|
|
29
|
+
// and dogfood-record.schema.json (["pass","fail","blocked","skip","partial"]). `partial`
|
|
30
|
+
// is a first-class, contract-blessed step status (verdict.js ranks it 2/3); omitting it
|
|
31
|
+
// here falsely rejected schema-valid submissions as "unknown status".
|
|
32
|
+
const VALID_STATUSES = new Set(['pass', 'fail', 'blocked', 'skip', 'partial']);
|
|
29
33
|
|
|
30
34
|
for (let i = 0; i < step_results.length; i++) {
|
|
31
35
|
const step = step_results[i];
|
|
@@ -48,8 +52,12 @@ export function validateStepResults(scenarioResult) {
|
|
|
48
52
|
|
|
49
53
|
// A scenario cannot be "pass" if any step is "fail" or "blocked"
|
|
50
54
|
if (verdict === 'pass') {
|
|
55
|
+
// Guard `s != null` to match the two sibling loops above: a null element is
|
|
56
|
+
// already reported as malformed there, and dereferencing `s.status` here
|
|
57
|
+
// would throw a TypeError that runValidator misclassifies as an operational
|
|
58
|
+
// VALIDATOR_FAULT_STEPS instead of a submission-bad signal (verify-B-004).
|
|
51
59
|
const failingSteps = step_results.filter(
|
|
52
|
-
s => s.status === 'fail' || s.status === 'blocked'
|
|
60
|
+
s => s != null && (s.status === 'fail' || s.status === 'blocked')
|
|
53
61
|
);
|
|
54
62
|
if (failingSteps.length > 0) {
|
|
55
63
|
const ids = failingSteps.map(s => s.step_id).join(', ');
|