@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/index.js
CHANGED
|
@@ -9,7 +9,28 @@
|
|
|
9
9
|
import { validateSubmissionSchema as _defaultValidateSubmissionSchema } from './validators/schema.js';
|
|
10
10
|
import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js';
|
|
11
11
|
import { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
|
|
12
|
+
import { validateSchemaVersion as _defaultValidateSchemaVersion } from './validators/schema-version.js';
|
|
12
13
|
import { computeVerdict } from './validators/verdict.js';
|
|
14
|
+
import { parseRunUrlRepo } from './validators/repo-binding.js';
|
|
15
|
+
import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
|
|
16
|
+
|
|
17
|
+
// F1-CONTRACTS-003: re-export the rejection-reason classifier from the package
|
|
18
|
+
// root so consumers `import { parseRejectionReason } from '@dogfood-lab/verify'`
|
|
19
|
+
// instead of hand-rolling .startsWith() chains over the prefix taxonomy.
|
|
20
|
+
export { parseRejectionReason } from './parse-rejection.js';
|
|
21
|
+
|
|
22
|
+
// Provider-keyed provenance selection. A submission's `source.provider` decides
|
|
23
|
+
// which provider API confirms its run; `provenanceForProvider(provider)` returns
|
|
24
|
+
// the matching adapter factory (or null for an unknown provider). Re-exported
|
|
25
|
+
// from the package root so the wiring layer selects by provider here rather than
|
|
26
|
+
// hand-rolling an if-chain over provider literals. The registry stays in lockstep
|
|
27
|
+
// with the source.provider enum via validators/provenance-registry.test.js.
|
|
28
|
+
export { provenanceForProvider, PROVENANCE_ADAPTERS } from './validators/provenance.js';
|
|
29
|
+
|
|
30
|
+
// F1-CONTRACTS-001: the persisted record's `schema_version` is the SINGLE
|
|
31
|
+
// source of truth from the contract package — not a hardcoded literal that
|
|
32
|
+
// can drift from `SUPPORTED_SCHEMA_VERSIONS.record.current`.
|
|
33
|
+
const RECORD_SCHEMA_VERSION = SUPPORTED_SCHEMA_VERSIONS.record.current;
|
|
13
34
|
|
|
14
35
|
/**
|
|
15
36
|
* D1B-003 (Stage C humanization): the SOLE catch wrapper for synchronous
|
|
@@ -75,7 +96,7 @@ export async function verify(submission, options) {
|
|
|
75
96
|
// timing.finished_at). Mark _skipPersist so the ingest layer surfaces the
|
|
76
97
|
// rejection without crashing the persist layer with `invalid repo format: undefined`.
|
|
77
98
|
return {
|
|
78
|
-
schema_version:
|
|
99
|
+
schema_version: RECORD_SCHEMA_VERSION,
|
|
79
100
|
_skipPersist: true,
|
|
80
101
|
verification: {
|
|
81
102
|
status: 'rejected',
|
|
@@ -83,7 +104,12 @@ export async function verify(submission, options) {
|
|
|
83
104
|
provenance_confirmed: false,
|
|
84
105
|
schema_valid: false,
|
|
85
106
|
policy_valid: false,
|
|
86
|
-
|
|
107
|
+
// verify-B-003: a null/non-object submission is a malfunctioning
|
|
108
|
+
// DISPATCHER (the caller handed us garbage), not a submitter who sent a
|
|
109
|
+
// bad-but-shaped payload. Carry a typed `submission-malformed:` prefix so
|
|
110
|
+
// parseRejectionReason classifies it 'operational' (page the runner) instead
|
|
111
|
+
// of bouncing an ops incident back to the submitter as 'unknown'.
|
|
112
|
+
rejection_reasons: ['submission-malformed: submission is null or not an object']
|
|
87
113
|
}
|
|
88
114
|
};
|
|
89
115
|
}
|
|
@@ -95,6 +121,7 @@ export async function verify(submission, options) {
|
|
|
95
121
|
const validateSubmissionSchema = validatorOverrides.validateSubmissionSchema || _defaultValidateSubmissionSchema;
|
|
96
122
|
const validateStepResults = validatorOverrides.validateStepResults || _defaultValidateStepResults;
|
|
97
123
|
const validatePolicy = validatorOverrides.validatePolicy || _defaultValidatePolicy;
|
|
124
|
+
const validateSchemaVersion = validatorOverrides.validateSchemaVersion || _defaultValidateSchemaVersion;
|
|
98
125
|
const now = new Date().toISOString();
|
|
99
126
|
const reasons = [];
|
|
100
127
|
|
|
@@ -104,13 +131,17 @@ export async function verify(submission, options) {
|
|
|
104
131
|
// real, legitimate run from their own repo. Provenance would confirm (the run
|
|
105
132
|
// exists), and the persist layer would file the record under victim-org's path
|
|
106
133
|
// — a forged "pass" verdict for a repo the submitter does not control.
|
|
107
|
-
//
|
|
134
|
+
//
|
|
135
|
+
// verify-B-001: the run_url shape is PROVIDER-specific, so the decode is keyed
|
|
136
|
+
// by `source.provider` in validators/repo-binding.js. RUN_URL_PARSERS there MUST
|
|
137
|
+
// stay in lockstep with the source.provider enum in the submission schema — a
|
|
138
|
+
// coverage test (validators/repo-binding.test.js) fails CI if a provider is
|
|
139
|
+
// added to the schema without a parser, so this binding can never silently
|
|
140
|
+
// no-op for a new provider (which would reopen the verify-A-001 forgery vector).
|
|
108
141
|
if (submission.repo && submission.source?.run_url) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (m) {
|
|
113
|
-
const sourceRepo = `${m[1]}/${m[2]}`;
|
|
142
|
+
const bound = parseRunUrlRepo(submission.source.provider, submission.source.run_url);
|
|
143
|
+
if (bound) {
|
|
144
|
+
const sourceRepo = `${bound.owner}/${bound.repo}`;
|
|
114
145
|
if (sourceRepo !== submission.repo) {
|
|
115
146
|
reasons.push(
|
|
116
147
|
`repo:mismatch: submission.repo (${submission.repo}) does not match source.run_url repo (${sourceRepo})`
|
|
@@ -134,6 +165,26 @@ export async function verify(submission, options) {
|
|
|
134
165
|
reasons.push(schemaRun.faultReason);
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
// 1b. schema_version VALUE gate (F1-CONTRACTS-001)
|
|
169
|
+
// The schema check above gates `schema_version` by PATTERN only. This gate
|
|
170
|
+
// compares the declared MAJOR against `SUPPORTED_SCHEMA_VERSIONS` (the
|
|
171
|
+
// single source of truth in @dogfood-lab/schemas) and refuses an
|
|
172
|
+
// incompatible major instead of silently mis-validating a future contract
|
|
173
|
+
// against the live 1.x schema. It runs INDEPENDENT of `schemaResult.valid`:
|
|
174
|
+
// a future-major payload may also fail shape, but the version refusal is the
|
|
175
|
+
// operator-actionable signal and must land regardless. The validator emits a
|
|
176
|
+
// fully-prefixed `CONTRACT_SCHEMA_TOO_NEW:` / `CONTRACT_SCHEMA_TOO_OLD:`
|
|
177
|
+
// reason; an unknown-contract throw surfaces as
|
|
178
|
+
// `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION` via the same runValidator seam.
|
|
179
|
+
const versionRun = runValidator('contract_schema_version', () => validateSchemaVersion(submission, 'recordSubmission'));
|
|
180
|
+
if (versionRun.ok) {
|
|
181
|
+
if (!versionRun.result.valid) {
|
|
182
|
+
reasons.push(...versionRun.result.errors);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
reasons.push(versionRun.faultReason);
|
|
186
|
+
}
|
|
187
|
+
|
|
137
188
|
// 2. Reject if submission includes verifier-owned fields
|
|
138
189
|
const verifierFields = ['policy_version', 'verification'];
|
|
139
190
|
for (const field of verifierFields) {
|
|
@@ -149,7 +200,14 @@ export async function verify(submission, options) {
|
|
|
149
200
|
let provenanceConfirmed = false;
|
|
150
201
|
if (schemaResult.valid && submission.source) {
|
|
151
202
|
try {
|
|
152
|
-
|
|
203
|
+
// verify-A-001: bind the PERSISTED commit (submission.ref.commit_sha) to the
|
|
204
|
+
// verified run. The record attests to this commit, so the provenance adapter
|
|
205
|
+
// must reject a run whose head differs from it — otherwise a real run can be
|
|
206
|
+
// pointed at an arbitrary persisted sha. Adapters that ignore the binding
|
|
207
|
+
// (stub/rejecting) accept the extra arg harmlessly.
|
|
208
|
+
provenanceConfirmed = await provenance.confirm(submission.source, {
|
|
209
|
+
refCommitSha: submission.ref?.commit_sha
|
|
210
|
+
});
|
|
153
211
|
} catch (err) {
|
|
154
212
|
reasons.push(`provenance: verification failed: ${err.message}`);
|
|
155
213
|
}
|
|
@@ -219,7 +277,7 @@ export async function verify(submission, options) {
|
|
|
219
277
|
|
|
220
278
|
// 7. Assemble persisted record
|
|
221
279
|
const persisted = {
|
|
222
|
-
schema_version:
|
|
280
|
+
schema_version: RECORD_SCHEMA_VERSION,
|
|
223
281
|
policy_version: policyVersion,
|
|
224
282
|
run_id: submission.run_id,
|
|
225
283
|
repo: submission.repo,
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/verify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dogfood-verify": "cli.js"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./index.js",
|
|
12
|
+
"./cli.js": "./cli.js",
|
|
13
|
+
"./parse-rejection.js": "./parse-rejection.js",
|
|
9
14
|
"./validators/*": "./validators/*",
|
|
10
15
|
"./validators/*.js": "./validators/*.js"
|
|
11
16
|
},
|
|
@@ -15,6 +20,8 @@
|
|
|
15
20
|
},
|
|
16
21
|
"files": [
|
|
17
22
|
"index.js",
|
|
23
|
+
"cli.js",
|
|
24
|
+
"parse-rejection.js",
|
|
18
25
|
"validators/",
|
|
19
26
|
"README.md",
|
|
20
27
|
"LICENSE"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse-rejection.js — F1-CONTRACTS-003 (Wave 4, MED)
|
|
3
|
+
*
|
|
4
|
+
* The verifier emits `verification.rejection_reasons` as an array of STRINGS
|
|
5
|
+
* carrying stable prefixes (see verify/README.md → "Prefix taxonomy"). Until
|
|
6
|
+
* now every operator discriminated failure class with hand-rolled
|
|
7
|
+
* `.startsWith()` chains, re-implementing the same taxonomy at each call site —
|
|
8
|
+
* a fresh drift source the moment a prefix is added.
|
|
9
|
+
*
|
|
10
|
+
* `parseRejectionReason(reason)` is the single exported classifier. It maps a
|
|
11
|
+
* raw rejection string to `{ class, prefix, detail }`:
|
|
12
|
+
*
|
|
13
|
+
* - class — the routing decision (who fixes this):
|
|
14
|
+
* 'submission-bad' → the submitter fixes the payload and resubmits
|
|
15
|
+
* 'operational' → the verifier/tooling faulted; page ops, do NOT
|
|
16
|
+
* bounce back to the submitter
|
|
17
|
+
* 'ingest' → an ingest-side load fault (scenario fetch)
|
|
18
|
+
* 'unknown' → unrecognized prefix; log + surface raw
|
|
19
|
+
* - prefix — the canonical prefix token that matched (e.g. `'schema:'`,
|
|
20
|
+
* `'steps[<id>]:'`), or `null` for the 'unknown' class.
|
|
21
|
+
* - detail — the human-readable remainder with the matched prefix stripped
|
|
22
|
+
* (for 'unknown', the whole string verbatim).
|
|
23
|
+
*
|
|
24
|
+
* The prefix vocabulary below is enumerated from the ACTUAL emitters — it is
|
|
25
|
+
* NOT invented:
|
|
26
|
+
* - verify/index.js: schema:, policy:, steps[<id>]:,
|
|
27
|
+
* provenance:, repo:,
|
|
28
|
+
* submission-contains-verifier-field:,
|
|
29
|
+
* submission-malformed:,
|
|
30
|
+
* VALIDATOR_FAULT_<NAME>:
|
|
31
|
+
* - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
|
|
32
|
+
* CONTRACT_SCHEMA_TOO_OLD:
|
|
33
|
+
* - packages/ingest/run.js: scenario-load:
|
|
34
|
+
*
|
|
35
|
+
* `VALIDATOR_FAULT_*` is matched by family, not by an exhaustive name list, so
|
|
36
|
+
* a future `VALIDATOR_FAULT_<NEW>:` (e.g. the runValidator seam adds a 5th
|
|
37
|
+
* validator) is classified 'operational' without a code change here. The
|
|
38
|
+
* `steps[<id>]:` prefix uses bracket syntax (`steps[step-7]:`), so it is matched
|
|
39
|
+
* by a small regex rather than a literal `.startsWith`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {'submission-bad' | 'operational' | 'ingest' | 'unknown'} RejectionClass
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} ParsedRejection
|
|
48
|
+
* @property {RejectionClass} class Routing decision (who must act).
|
|
49
|
+
* @property {string | null} prefix Canonical prefix token, or null when unknown.
|
|
50
|
+
* @property {string} detail Human-readable remainder (prefix stripped).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Literal-prefix matchers, ordered most-specific-first. Each carries the
|
|
55
|
+
* canonical prefix token and the routing class. `match` is the exact string the
|
|
56
|
+
* reason must start with; `detail` is whatever follows it (trimmed of one
|
|
57
|
+
* leading space). `repo:` matches the `repo:mismatch: …` family — the canonical
|
|
58
|
+
* token reported is the stable `repo:` head.
|
|
59
|
+
*/
|
|
60
|
+
const LITERAL_PREFIXES = [
|
|
61
|
+
// submission-bad
|
|
62
|
+
{ match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
|
|
63
|
+
{ match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
|
|
64
|
+
{ match: 'provenance:', prefix: 'provenance:', class: 'submission-bad' },
|
|
65
|
+
{ match: 'repo:', prefix: 'repo:', class: 'submission-bad' },
|
|
66
|
+
{
|
|
67
|
+
match: 'submission-contains-verifier-field:',
|
|
68
|
+
prefix: 'submission-contains-verifier-field:',
|
|
69
|
+
class: 'submission-bad',
|
|
70
|
+
},
|
|
71
|
+
{ match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'submission-bad' },
|
|
72
|
+
{ match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad' },
|
|
73
|
+
// operational — a null/non-object submission is a malfunctioning dispatcher
|
|
74
|
+
// (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
|
|
75
|
+
// do NOT bounce it back to the submitter.
|
|
76
|
+
{ match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
|
|
77
|
+
// ingest
|
|
78
|
+
{ match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** Strip a matched literal prefix and one optional leading space from a reason. */
|
|
82
|
+
function stripLiteral(reason, match) {
|
|
83
|
+
return reason.slice(match.length).replace(/^\s+/, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Classify a single verifier/ingest rejection-reason string.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} reason A rejection string (typically from a persisted
|
|
90
|
+
* record's `verification.rejection_reasons[]`).
|
|
91
|
+
* @returns {ParsedRejection}
|
|
92
|
+
*/
|
|
93
|
+
export function parseRejectionReason(reason) {
|
|
94
|
+
if (typeof reason !== 'string') {
|
|
95
|
+
return { class: 'unknown', prefix: null, detail: '' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 1. Operational faults — matched by FAMILY so future VALIDATOR_FAULT_<NEW>
|
|
99
|
+
// classes need no edit here. (runValidator emits `VALIDATOR_FAULT_<CLS>: …`.)
|
|
100
|
+
const faultMatch = reason.match(/^(VALIDATOR_FAULT_[A-Z0-9_]+):\s*/);
|
|
101
|
+
if (faultMatch) {
|
|
102
|
+
return {
|
|
103
|
+
class: 'operational',
|
|
104
|
+
prefix: `${faultMatch[1]}:`,
|
|
105
|
+
detail: reason.slice(faultMatch[0].length),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. steps[<id>]: — bracketed id, so matched by regex rather than a literal.
|
|
110
|
+
const stepsMatch = reason.match(/^steps\[[^\]]*\]:\s*/);
|
|
111
|
+
if (stepsMatch) {
|
|
112
|
+
return {
|
|
113
|
+
class: 'submission-bad',
|
|
114
|
+
prefix: 'steps[<id>]:',
|
|
115
|
+
detail: reason.slice(stepsMatch[0].length),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Literal prefixes (most-specific-first; the array order guards against a
|
|
120
|
+
// shorter prefix shadowing a longer one — none currently overlap, but the
|
|
121
|
+
// ordering keeps that invariant explicit).
|
|
122
|
+
for (const entry of LITERAL_PREFIXES) {
|
|
123
|
+
if (reason.startsWith(entry.match)) {
|
|
124
|
+
return {
|
|
125
|
+
class: entry.class,
|
|
126
|
+
prefix: entry.prefix,
|
|
127
|
+
detail: stripLiteral(reason, entry.match),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 4. Unrecognized — log + surface raw. (The null/non-object submission reason
|
|
133
|
+
// now carries the typed `submission-malformed:` prefix → 'operational', so it
|
|
134
|
+
// no longer falls through here; see verify-B-003.)
|
|
135
|
+
return { class: 'unknown', prefix: null, detail: reason };
|
|
136
|
+
}
|
package/validators/policy.js
CHANGED
|
@@ -156,7 +156,15 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
156
156
|
|
|
157
157
|
if (ciReqs.coverage_min != null) {
|
|
158
158
|
const coverageCheck = submission.ci_checks?.find(c => c.kind === 'coverage');
|
|
159
|
-
|
|
159
|
+
// ci_checks[].value is OPTIONAL in the submission schema (only id/kind/status
|
|
160
|
+
// are required). A value-less coverage check must be treated the SAME as a
|
|
161
|
+
// missing one: at the trust boundary the verifier rejects incomplete proof.
|
|
162
|
+
// Without this guard, `undefined < coverage_min` is false (and a coerced
|
|
163
|
+
// null/NaN is likewise not a real measurement), so the gate silently passed
|
|
164
|
+
// with no measured coverage.
|
|
165
|
+
const measured = coverageCheck && typeof coverageCheck.value === 'number'
|
|
166
|
+
&& Number.isFinite(coverageCheck.value);
|
|
167
|
+
if (!coverageCheck || !measured) {
|
|
160
168
|
errors.push(
|
|
161
169
|
`surface[${surface}]: coverage_min is ${ciReqs.coverage_min}% but no coverage data provided`
|
|
162
170
|
);
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provenance-gitlab.test.js — GitLab CI provenance adapter (peer of githubProvenance)
|
|
3
|
+
*
|
|
4
|
+
* GitLab is the SECOND provenance provider. The adapter mirrors githubProvenance's
|
|
5
|
+
* hardening exactly:
|
|
6
|
+
* - per-request AbortController timeout → throws `provenance: GitLab API timeout`
|
|
7
|
+
* - returns false ONLY on HTTP 404 (pipeline/job genuinely absent)
|
|
8
|
+
* - THROWS on 401/403/429/5xx as OPERATIONAL (so parseRejectionReason routes the
|
|
9
|
+
* fault to ops, not back to the submitter) — same `provenance:` prefix as GitHub
|
|
10
|
+
* - asserts the pipeline/job reached a FINISHED/SUCCESS state
|
|
11
|
+
* - binds the confirmed commit sha to the PERSISTED commit (expected.refCommitSha,
|
|
12
|
+
* the verify-A-001 anti-forgery guard) mandatorily, and binds the project path
|
|
13
|
+
* to source.repo
|
|
14
|
+
* - injectable fetch (opts.fetchImpl) so tests need no network
|
|
15
|
+
*
|
|
16
|
+
* Anti-forgery test id mirrored from GitHub: verify-A-001 (ref.commit_sha binding).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
|
|
22
|
+
import { gitlabProvenance } from './provenance.js';
|
|
23
|
+
|
|
24
|
+
// A schema-shaped GitLab source. `provider_run_id` is the pipeline (or job) id;
|
|
25
|
+
// `run_url` is a GitLab pipeline/job URL the repo-binding layer can decode.
|
|
26
|
+
function pipelineSource(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
provider: 'gitlab',
|
|
29
|
+
workflow: '.gitlab-ci.yml',
|
|
30
|
+
provider_run_id: '424242',
|
|
31
|
+
run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
|
|
32
|
+
repo: 'acme/widget',
|
|
33
|
+
...overrides
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function jobSource(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
provider: 'gitlab',
|
|
40
|
+
workflow: '.gitlab-ci.yml',
|
|
41
|
+
provider_run_id: '987654',
|
|
42
|
+
run_url: 'https://gitlab.com/acme/widget/-/jobs/987654',
|
|
43
|
+
repo: 'acme/widget',
|
|
44
|
+
...overrides
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
49
|
+
const FORGED = 'f0f0f0f0000000000000000000000000baddecaf';
|
|
50
|
+
|
|
51
|
+
// GitLab pipeline payload (GET /projects/:id/pipelines/:pipeline_id).
|
|
52
|
+
function mockPipeline(overrides = {}) {
|
|
53
|
+
return {
|
|
54
|
+
id: 424242,
|
|
55
|
+
status: 'success',
|
|
56
|
+
sha: RUN_HEAD,
|
|
57
|
+
...overrides
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// GitLab job payload (GET /projects/:id/jobs/:job_id). The commit lives under
|
|
62
|
+
// `commit.id` rather than a top-level `sha`.
|
|
63
|
+
function mockJob(overrides = {}) {
|
|
64
|
+
return {
|
|
65
|
+
id: 987654,
|
|
66
|
+
status: 'success',
|
|
67
|
+
commit: { id: RUN_HEAD },
|
|
68
|
+
...overrides
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fetchReturning(body) {
|
|
73
|
+
return async () => ({ ok: true, status: 200, json: async () => body });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fetchWithStatus(status) {
|
|
77
|
+
return async () => ({ ok: false, status, json: async () => ({}) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Happy path: well-formed finished pipeline/job is confirmed ────
|
|
81
|
+
|
|
82
|
+
describe('gitlabProvenance confirms a finished pipeline/job', () => {
|
|
83
|
+
it('confirms a successful pipeline whose sha === refCommitSha and project === source.repo', async () => {
|
|
84
|
+
const adapter = gitlabProvenance('token', {
|
|
85
|
+
timeoutMs: 1000,
|
|
86
|
+
fetchImpl: fetchReturning(mockPipeline())
|
|
87
|
+
});
|
|
88
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
89
|
+
assert.equal(ok, true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('confirms a successful JOB whose commit.id === refCommitSha', async () => {
|
|
93
|
+
const adapter = gitlabProvenance('token', {
|
|
94
|
+
timeoutMs: 1000,
|
|
95
|
+
fetchImpl: fetchReturning(mockJob())
|
|
96
|
+
});
|
|
97
|
+
const ok = await adapter.confirm(jobSource(), { refCommitSha: RUN_HEAD });
|
|
98
|
+
assert.equal(ok, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('binds the project path to source.repo (rejects a mismatched project)', async () => {
|
|
102
|
+
const adapter = gitlabProvenance('token', {
|
|
103
|
+
timeoutMs: 1000,
|
|
104
|
+
fetchImpl: fetchReturning(mockPipeline())
|
|
105
|
+
});
|
|
106
|
+
const ok = await adapter.confirm(
|
|
107
|
+
pipelineSource({ repo: 'victim/repo', run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242' }),
|
|
108
|
+
{ refCommitSha: RUN_HEAD }
|
|
109
|
+
);
|
|
110
|
+
assert.equal(ok, false, 'source.repo not matching the run_url project must not confirm');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── verify-A-001: anti-forgery ref.commit_sha binding (mirrors GitHub) ──
|
|
115
|
+
//
|
|
116
|
+
// HIGH/security: the commit a record attests to is submission.ref.commit_sha
|
|
117
|
+
// (index.js persists submission.ref verbatim). A submitter who owns a real
|
|
118
|
+
// finished pipeline could otherwise point ref.commit_sha at any 40-hex sha and
|
|
119
|
+
// earn a provenance_confirmed 'pass' for a commit the pipeline never executed.
|
|
120
|
+
// gitlabProvenance binds the pipeline sha to the persisted commit and rejects
|
|
121
|
+
// any mismatch — identical guard to githubProvenance.
|
|
122
|
+
|
|
123
|
+
describe('gitlabProvenance binds ref.commit_sha to the run (verify-A-001)', () => {
|
|
124
|
+
it('rejects when the persisted ref.commit_sha differs from the pipeline sha', async () => {
|
|
125
|
+
const adapter = gitlabProvenance('token', {
|
|
126
|
+
timeoutMs: 1000,
|
|
127
|
+
fetchImpl: fetchReturning(mockPipeline())
|
|
128
|
+
});
|
|
129
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: FORGED });
|
|
130
|
+
assert.equal(ok, false,
|
|
131
|
+
'a ref.commit_sha that does not match the confirmed pipeline sha must NOT be confirmed');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('confirms when the persisted ref.commit_sha matches the pipeline sha', async () => {
|
|
135
|
+
const adapter = gitlabProvenance('token', {
|
|
136
|
+
timeoutMs: 1000,
|
|
137
|
+
fetchImpl: fetchReturning(mockPipeline())
|
|
138
|
+
});
|
|
139
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
140
|
+
assert.equal(ok, true,
|
|
141
|
+
'a ref.commit_sha equal to the confirmed pipeline sha must be confirmed');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects a forged commit on the JOB path too (commit.id binding)', async () => {
|
|
145
|
+
const adapter = gitlabProvenance('token', {
|
|
146
|
+
timeoutMs: 1000,
|
|
147
|
+
fetchImpl: fetchReturning(mockJob())
|
|
148
|
+
});
|
|
149
|
+
const ok = await adapter.confirm(jobSource(), { refCommitSha: FORGED });
|
|
150
|
+
assert.equal(ok, false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── Finished-state guard (mirrors githubProvenance run.status === 'completed') ──
|
|
155
|
+
|
|
156
|
+
describe('gitlabProvenance requires a finished/success state', () => {
|
|
157
|
+
for (const status of ['running', 'pending', 'created', 'preparing', 'waiting_for_resource', 'scheduled', 'manual']) {
|
|
158
|
+
it(`rejects a pipeline with non-finished status: ${status}`, async () => {
|
|
159
|
+
const adapter = gitlabProvenance('token', {
|
|
160
|
+
timeoutMs: 1000,
|
|
161
|
+
fetchImpl: fetchReturning(mockPipeline({ status }))
|
|
162
|
+
});
|
|
163
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
164
|
+
assert.equal(ok, false, `${status} pipeline must not be confirmed`);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
it('confirms a successful pipeline (status: success)', async () => {
|
|
169
|
+
const adapter = gitlabProvenance('token', {
|
|
170
|
+
timeoutMs: 1000,
|
|
171
|
+
fetchImpl: fetchReturning(mockPipeline({ status: 'success' }))
|
|
172
|
+
});
|
|
173
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
174
|
+
assert.equal(ok, true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('confirms a finished pipeline even when it FAILED (provenance = ran, not passed)', async () => {
|
|
178
|
+
// Mirror githubProvenance: provenance confirms the pipeline EXECUTED to a
|
|
179
|
+
// terminal state. Pass/fail is a separate signal (ci_checks / scenario
|
|
180
|
+
// verdicts). 'failed' is terminal, so it confirms.
|
|
181
|
+
const adapter = gitlabProvenance('token', {
|
|
182
|
+
timeoutMs: 1000,
|
|
183
|
+
fetchImpl: fetchReturning(mockPipeline({ status: 'failed' }))
|
|
184
|
+
});
|
|
185
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
186
|
+
assert.equal(ok, true, 'verifier confirms the pipeline reached a terminal state');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('confirms a canceled pipeline (terminal state)', async () => {
|
|
190
|
+
const adapter = gitlabProvenance('token', {
|
|
191
|
+
timeoutMs: 1000,
|
|
192
|
+
fetchImpl: fetchReturning(mockPipeline({ status: 'canceled' }))
|
|
193
|
+
});
|
|
194
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
195
|
+
assert.equal(ok, true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── 404 vs operational (mirrors verify-A-002) ──────────────────────
|
|
200
|
+
|
|
201
|
+
describe('gitlabProvenance distinguishes ops failures from missing runs', () => {
|
|
202
|
+
it('returns false (run genuinely absent) on HTTP 404', async () => {
|
|
203
|
+
const adapter = gitlabProvenance('token', {
|
|
204
|
+
timeoutMs: 1000,
|
|
205
|
+
fetchImpl: fetchWithStatus(404)
|
|
206
|
+
});
|
|
207
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
208
|
+
assert.equal(ok, false, '404 means the pipeline does not exist — a real rejection, not an outage');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
for (const status of [401, 403, 429, 500, 503]) {
|
|
212
|
+
it(`throws an operational error on HTTP ${status} (not a submission-bad false)`, async () => {
|
|
213
|
+
const adapter = gitlabProvenance('token', {
|
|
214
|
+
timeoutMs: 1000,
|
|
215
|
+
fetchImpl: fetchWithStatus(status)
|
|
216
|
+
});
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
219
|
+
err => {
|
|
220
|
+
assert.match(err.message, /provenance: GitLab API returned/);
|
|
221
|
+
assert.match(err.message, new RegExp(String(status)));
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── Timeout (mirrors F-246817-014) ─────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe('gitlabProvenance fetch timeout', () => {
|
|
232
|
+
function makeHangingFetch() {
|
|
233
|
+
return function hangingFetch(_url, opts) {
|
|
234
|
+
return new Promise((_resolve, reject) => {
|
|
235
|
+
if (opts && opts.signal) {
|
|
236
|
+
opts.signal.addEventListener('abort', () => {
|
|
237
|
+
const err = new Error('aborted');
|
|
238
|
+
err.name = 'AbortError';
|
|
239
|
+
reject(err);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it('throws timeout error when fetch hangs longer than timeoutMs', async () => {
|
|
247
|
+
const adapter = gitlabProvenance('token', {
|
|
248
|
+
timeoutMs: 50,
|
|
249
|
+
fetchImpl: makeHangingFetch()
|
|
250
|
+
});
|
|
251
|
+
const start = Date.now();
|
|
252
|
+
await assert.rejects(
|
|
253
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
254
|
+
err => {
|
|
255
|
+
assert.match(err.message, /provenance: GitLab API timeout/);
|
|
256
|
+
assert.match(err.message, /50ms/);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
assert.ok(Date.now() - start < 5000, 'expected fast abort');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('returns false (not throws) on non-AbortError transport failures', async () => {
|
|
264
|
+
const failingFetch = async () => { throw new Error('connection refused'); };
|
|
265
|
+
const adapter = gitlabProvenance('token', {
|
|
266
|
+
timeoutMs: 1000,
|
|
267
|
+
fetchImpl: failingFetch
|
|
268
|
+
});
|
|
269
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
270
|
+
assert.equal(ok, false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Provider guard + malformed input ───────────────────────────────
|
|
275
|
+
|
|
276
|
+
describe('gitlabProvenance input guards', () => {
|
|
277
|
+
it('throws on a non-gitlab provider', async () => {
|
|
278
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
279
|
+
await assert.rejects(
|
|
280
|
+
adapter.confirm({ provider: 'github', provider_run_id: '1', run_url: 'https://github.com/a/b/actions/runs/1' }),
|
|
281
|
+
/unsupported provider: github/
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('returns false when run_url is missing', async () => {
|
|
286
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
287
|
+
const ok = await adapter.confirm({ provider: 'gitlab', provider_run_id: '1' });
|
|
288
|
+
assert.equal(ok, false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('returns false when run_url does not match the GitLab pipeline/job shape', async () => {
|
|
292
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
293
|
+
const ok = await adapter.confirm({
|
|
294
|
+
provider: 'gitlab',
|
|
295
|
+
provider_run_id: '1',
|
|
296
|
+
run_url: 'https://gitlab.com/acme/widget/-/merge_requests/1'
|
|
297
|
+
});
|
|
298
|
+
assert.equal(ok, false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -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
|
+
});
|