@dogfood-lab/verify 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/cli.js +447 -0
- package/index.js +50 -11
- package/package.json +8 -1
- package/parse-rejection.js +146 -0
- package/validators/policy.js +83 -3
- package/validators/provenance-gitlab.test.js +375 -0
- package/validators/provenance-registry.test.js +56 -0
- package/validators/provenance.js +331 -31
- package/validators/repo-binding.js +92 -0
- package/validators/repo-binding.test.js +118 -0
- package/validators/schema.js +8 -17
- package/validators/steps.js +5 -1
|
@@ -0,0 +1,146 @@
|
|
|
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: (run absent → submission-bad),
|
|
28
|
+
* provenance-fault: (provider 429/5xx/401/403
|
|
29
|
+
* → operational), repo:,
|
|
30
|
+
* submission-contains-verifier-field:,
|
|
31
|
+
* submission-malformed:,
|
|
32
|
+
* VALIDATOR_FAULT_<NAME>:
|
|
33
|
+
* - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
|
|
34
|
+
* CONTRACT_SCHEMA_TOO_OLD:
|
|
35
|
+
* - packages/ingest/run.js: scenario-load:
|
|
36
|
+
*
|
|
37
|
+
* `VALIDATOR_FAULT_*` is matched by family, not by an exhaustive name list, so
|
|
38
|
+
* a future `VALIDATOR_FAULT_<NEW>:` (e.g. the runValidator seam adds a 5th
|
|
39
|
+
* validator) is classified 'operational' without a code change here. The
|
|
40
|
+
* `steps[<id>]:` prefix uses bracket syntax (`steps[step-7]:`), so it is matched
|
|
41
|
+
* by a small regex rather than a literal `.startsWith`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {'submission-bad' | 'operational' | 'ingest' | 'unknown'} RejectionClass
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} ParsedRejection
|
|
50
|
+
* @property {RejectionClass} class Routing decision (who must act).
|
|
51
|
+
* @property {string | null} prefix Canonical prefix token, or null when unknown.
|
|
52
|
+
* @property {string} detail Human-readable remainder (prefix stripped).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Literal-prefix matchers, ordered most-specific-first. Each carries the
|
|
57
|
+
* canonical prefix token and the routing class. `match` is the exact string the
|
|
58
|
+
* reason must start with; `detail` is whatever follows it (trimmed of one
|
|
59
|
+
* leading space). `repo:` matches the `repo:mismatch: …` family — the canonical
|
|
60
|
+
* token reported is the stable `repo:` head.
|
|
61
|
+
*/
|
|
62
|
+
const LITERAL_PREFIXES = [
|
|
63
|
+
// submission-bad
|
|
64
|
+
{ match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
|
|
65
|
+
{ match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
|
|
66
|
+
// operational — a provider-side provenance FAULT (429/5xx/401/403). The
|
|
67
|
+
// adapter THROWS these (verify-A-002); index.js catches the throw and emits
|
|
68
|
+
// this distinct `provenance-fault:` prefix so the incident pages ops. Ordered
|
|
69
|
+
// before the bare `provenance:` literal — it is a distinct token, but keeping
|
|
70
|
+
// the longer, more-specific match first preserves the most-specific-first
|
|
71
|
+
// invariant the array is sorted by. The genuine not-confirmed case
|
|
72
|
+
// (`provenance: source run could not be confirmed`) stays submission-bad below.
|
|
73
|
+
{ match: 'provenance-fault:', prefix: 'provenance-fault:', class: 'operational' },
|
|
74
|
+
{ match: 'provenance:', prefix: 'provenance:', class: 'submission-bad' },
|
|
75
|
+
{ match: 'repo:', prefix: 'repo:', class: 'submission-bad' },
|
|
76
|
+
{
|
|
77
|
+
match: 'submission-contains-verifier-field:',
|
|
78
|
+
prefix: 'submission-contains-verifier-field:',
|
|
79
|
+
class: 'submission-bad',
|
|
80
|
+
},
|
|
81
|
+
{ match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'submission-bad' },
|
|
82
|
+
{ match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad' },
|
|
83
|
+
// operational — a null/non-object submission is a malfunctioning dispatcher
|
|
84
|
+
// (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
|
|
85
|
+
// do NOT bounce it back to the submitter.
|
|
86
|
+
{ match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
|
|
87
|
+
// ingest
|
|
88
|
+
{ match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** Strip a matched literal prefix and one optional leading space from a reason. */
|
|
92
|
+
function stripLiteral(reason, match) {
|
|
93
|
+
return reason.slice(match.length).replace(/^\s+/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Classify a single verifier/ingest rejection-reason string.
|
|
98
|
+
*
|
|
99
|
+
* @param {unknown} reason A rejection string (typically from a persisted
|
|
100
|
+
* record's `verification.rejection_reasons[]`).
|
|
101
|
+
* @returns {ParsedRejection}
|
|
102
|
+
*/
|
|
103
|
+
export function parseRejectionReason(reason) {
|
|
104
|
+
if (typeof reason !== 'string') {
|
|
105
|
+
return { class: 'unknown', prefix: null, detail: '' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 1. Operational faults — matched by FAMILY so future VALIDATOR_FAULT_<NEW>
|
|
109
|
+
// classes need no edit here. (runValidator emits `VALIDATOR_FAULT_<CLS>: …`.)
|
|
110
|
+
const faultMatch = reason.match(/^(VALIDATOR_FAULT_[A-Z0-9_]+):\s*/);
|
|
111
|
+
if (faultMatch) {
|
|
112
|
+
return {
|
|
113
|
+
class: 'operational',
|
|
114
|
+
prefix: `${faultMatch[1]}:`,
|
|
115
|
+
detail: reason.slice(faultMatch[0].length),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. steps[<id>]: — bracketed id, so matched by regex rather than a literal.
|
|
120
|
+
const stepsMatch = reason.match(/^steps\[[^\]]*\]:\s*/);
|
|
121
|
+
if (stepsMatch) {
|
|
122
|
+
return {
|
|
123
|
+
class: 'submission-bad',
|
|
124
|
+
prefix: 'steps[<id>]:',
|
|
125
|
+
detail: reason.slice(stepsMatch[0].length),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Literal prefixes (most-specific-first; the array order guards against a
|
|
130
|
+
// shorter prefix shadowing a longer one — none currently overlap, but the
|
|
131
|
+
// ordering keeps that invariant explicit).
|
|
132
|
+
for (const entry of LITERAL_PREFIXES) {
|
|
133
|
+
if (reason.startsWith(entry.match)) {
|
|
134
|
+
return {
|
|
135
|
+
class: entry.class,
|
|
136
|
+
prefix: entry.prefix,
|
|
137
|
+
detail: stripLiteral(reason, entry.match),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 4. Unrecognized — log + surface raw. (The null/non-object submission reason
|
|
143
|
+
// now carries the typed `submission-malformed:` prefix → 'operational', so it
|
|
144
|
+
// no longer falls through here; see verify-B-003.)
|
|
145
|
+
return { class: 'unknown', prefix: null, detail: reason };
|
|
146
|
+
}
|
package/validators/policy.js
CHANGED
|
@@ -29,6 +29,31 @@ function deepMerge(target, source) {
|
|
|
29
29
|
return result;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Global reject-rule ids the build can account for. Two groups:
|
|
34
|
+
* - HANDLED HERE — enforced by the switch below.
|
|
35
|
+
* - ENFORCED ELSEWHERE — owned by another validator or by verify() itself
|
|
36
|
+
* (the switch's `default` arm intentionally no-ops them).
|
|
37
|
+
*
|
|
38
|
+
* PROACT-VERIFY-002: any `severity: reject` rule whose id is OUTSIDE this set is
|
|
39
|
+
* an operator-added gate the build does not enforce. Silently no-op'ing it (the
|
|
40
|
+
* old `default: break`) meant the rule looked active in global-policy.yaml but
|
|
41
|
+
* never ran. We now surface an actionable diagnostic instead. Keep this set in
|
|
42
|
+
* sync when a new reject rule gains real enforcement.
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_REJECT_RULE_IDS = new Set([
|
|
45
|
+
// handled by the switch in validatePolicy
|
|
46
|
+
'scenario-minimum',
|
|
47
|
+
'attested-if-human',
|
|
48
|
+
'blocked-needs-reason',
|
|
49
|
+
// enforced by other validators or by verify() itself (default-arm no-op is correct)
|
|
50
|
+
'schema-valid',
|
|
51
|
+
'provenance-confirmed',
|
|
52
|
+
'step-results-present',
|
|
53
|
+
'step-verdict-consistent',
|
|
54
|
+
'no-verdict-upgrade',
|
|
55
|
+
]);
|
|
56
|
+
|
|
32
57
|
/**
|
|
33
58
|
* Resolve the effective surface policy for a given product surface.
|
|
34
59
|
* Repo policy overrides global defaults per surface.
|
|
@@ -55,16 +80,32 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
|
|
|
55
80
|
* @param {object} options
|
|
56
81
|
* @param {object} options.globalPolicy
|
|
57
82
|
* @param {object|null} options.repoPolicy
|
|
58
|
-
* @returns {{ valid: boolean, errors: string[] }}
|
|
83
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
59
84
|
*/
|
|
60
85
|
export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
61
86
|
const errors = [];
|
|
87
|
+
// VERIFY-F4: warn-severity rules record an accepted-with-warning note here
|
|
88
|
+
// instead of being silently dropped. A populated warnings[] never affects
|
|
89
|
+
// `valid` — the caller (index.js) routes it to verification.warnings.
|
|
90
|
+
const warnings = [];
|
|
62
91
|
|
|
63
92
|
// --- Global rules (non-overridable) ---
|
|
64
93
|
|
|
65
94
|
const globalRules = globalPolicy.global_rules || [];
|
|
66
95
|
|
|
67
96
|
for (const rule of globalRules) {
|
|
97
|
+
// VERIFY-F4: a global warn-rule is accepted-with-warning; an info-rule logs
|
|
98
|
+
// only. Neither may reject. Mirrors the PROACT-VERIFY-002 discipline of not
|
|
99
|
+
// dropping operator-authored rules on the floor: a declared rule that the
|
|
100
|
+
// build does nothing with is invisible to the operator who wrote it.
|
|
101
|
+
if (rule.severity === 'warn') {
|
|
102
|
+
warnings.push(`${rule.id}: ${rule.description || 'policy warning'}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (rule.severity === 'info') {
|
|
106
|
+
// Logged only — info rules are intentionally non-surfacing in the record.
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
68
109
|
if (rule.severity !== 'reject') continue;
|
|
69
110
|
|
|
70
111
|
switch (rule.id) {
|
|
@@ -95,8 +136,20 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
95
136
|
break;
|
|
96
137
|
|
|
97
138
|
// schema-valid, provenance-confirmed, step-results-present, step-verdict-consistent,
|
|
98
|
-
// no-verdict-upgrade are enforced by other validators or the main verify() function
|
|
139
|
+
// no-verdict-upgrade are enforced by other validators or the main verify() function.
|
|
140
|
+
// PROACT-VERIFY-002: a reject rule the build neither handles here NOR enforces
|
|
141
|
+
// elsewhere would otherwise pass SILENTLY — the operator's new gate never runs.
|
|
142
|
+
// Reject the submission with a diagnostic naming the unenforced rule so the gap
|
|
143
|
+
// is visible instead of failing open.
|
|
99
144
|
default:
|
|
145
|
+
if (!KNOWN_REJECT_RULE_IDS.has(rule.id)) {
|
|
146
|
+
// No `policy:` prefix here — index.js prepends it to every policy
|
|
147
|
+
// error (mirrors the `[rule.id]` / `surface[...]` messages above).
|
|
148
|
+
errors.push(
|
|
149
|
+
`rule "${rule.id}" is declared severity:reject but has no enforcement in this build — ` +
|
|
150
|
+
`add an enforcement arm in validators/policy.js or register it in KNOWN_REJECT_RULE_IDS`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
100
153
|
break;
|
|
101
154
|
}
|
|
102
155
|
}
|
|
@@ -134,6 +187,33 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
134
187
|
}
|
|
135
188
|
}
|
|
136
189
|
}
|
|
190
|
+
|
|
191
|
+
// VERIFY-F2: tag gating. forbidden_tags rejects a scenario_result carrying
|
|
192
|
+
// any listed tag; required_tags rejects one missing any listed tag (a
|
|
193
|
+
// tagless scenario fails every required_tags rule). Tags are optional on
|
|
194
|
+
// the scenario_result, so an absent `tags` array trips required_tags but
|
|
195
|
+
// never forbidden_tags.
|
|
196
|
+
const tags = new Set(sr.tags || []);
|
|
197
|
+
|
|
198
|
+
if (evidenceReqs.forbidden_tags) {
|
|
199
|
+
for (const tag of evidenceReqs.forbidden_tags) {
|
|
200
|
+
if (tags.has(tag)) {
|
|
201
|
+
errors.push(
|
|
202
|
+
`surface[${surface}]: scenario "${sr.scenario_id}" carries forbidden tag "${tag}"`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (evidenceReqs.required_tags) {
|
|
209
|
+
for (const tag of evidenceReqs.required_tags) {
|
|
210
|
+
if (!tags.has(tag)) {
|
|
211
|
+
errors.push(
|
|
212
|
+
`surface[${surface}]: scenario "${sr.scenario_id}" is missing required tag "${tag}"`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
137
217
|
}
|
|
138
218
|
}
|
|
139
219
|
|
|
@@ -176,5 +256,5 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
176
256
|
}
|
|
177
257
|
}
|
|
178
258
|
|
|
179
|
-
return { valid: errors.length === 0, errors };
|
|
259
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
180
260
|
}
|
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
retries: 0,
|
|
216
|
+
fetchImpl: fetchWithStatus(status)
|
|
217
|
+
});
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
220
|
+
err => {
|
|
221
|
+
assert.match(err.message, /provenance: GitLab API returned/);
|
|
222
|
+
assert.match(err.message, new RegExp(String(status)));
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── Timeout (mirrors F-246817-014) ─────────────────────────────────
|
|
231
|
+
|
|
232
|
+
describe('gitlabProvenance fetch timeout', () => {
|
|
233
|
+
function makeHangingFetch() {
|
|
234
|
+
return function hangingFetch(_url, opts) {
|
|
235
|
+
return new Promise((_resolve, reject) => {
|
|
236
|
+
if (opts && opts.signal) {
|
|
237
|
+
opts.signal.addEventListener('abort', () => {
|
|
238
|
+
const err = new Error('aborted');
|
|
239
|
+
err.name = 'AbortError';
|
|
240
|
+
reject(err);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it('throws timeout error when fetch hangs longer than timeoutMs', async () => {
|
|
248
|
+
const adapter = gitlabProvenance('token', {
|
|
249
|
+
timeoutMs: 50,
|
|
250
|
+
fetchImpl: makeHangingFetch()
|
|
251
|
+
});
|
|
252
|
+
const start = Date.now();
|
|
253
|
+
await assert.rejects(
|
|
254
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
255
|
+
err => {
|
|
256
|
+
assert.match(err.message, /provenance: GitLab API timeout/);
|
|
257
|
+
assert.match(err.message, /50ms/);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
assert.ok(Date.now() - start < 5000, 'expected fast abort');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('returns false (not throws) on non-AbortError transport failures', async () => {
|
|
265
|
+
const failingFetch = async () => { throw new Error('connection refused'); };
|
|
266
|
+
const adapter = gitlabProvenance('token', {
|
|
267
|
+
timeoutMs: 1000,
|
|
268
|
+
fetchImpl: failingFetch
|
|
269
|
+
});
|
|
270
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
271
|
+
assert.equal(ok, false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── Provider guard + malformed input ───────────────────────────────
|
|
276
|
+
|
|
277
|
+
describe('gitlabProvenance input guards', () => {
|
|
278
|
+
it('throws on a non-gitlab provider', async () => {
|
|
279
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
280
|
+
await assert.rejects(
|
|
281
|
+
adapter.confirm({ provider: 'github', provider_run_id: '1', run_url: 'https://github.com/a/b/actions/runs/1' }),
|
|
282
|
+
/unsupported provider: github/
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('returns false when run_url is missing', async () => {
|
|
287
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
288
|
+
const ok = await adapter.confirm({ provider: 'gitlab', provider_run_id: '1' });
|
|
289
|
+
assert.equal(ok, false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns false when run_url does not match the GitLab pipeline/job shape', async () => {
|
|
293
|
+
const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
|
|
294
|
+
const ok = await adapter.confirm({
|
|
295
|
+
provider: 'gitlab',
|
|
296
|
+
provider_run_id: '1',
|
|
297
|
+
run_url: 'https://gitlab.com/acme/widget/-/merge_requests/1'
|
|
298
|
+
});
|
|
299
|
+
assert.equal(ok, false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── Bounded retry over transient faults (PROACT-VERIFY-001, mirrors GitHub) ──
|
|
304
|
+
//
|
|
305
|
+
// MEDIUM/resilience: a single 429/5xx blip used to fail the submission as an
|
|
306
|
+
// operational incident. The adapter now retries 429/5xx within a bounded budget
|
|
307
|
+
// (opts.retries, default 2) with exponential backoff (honoring Retry-After), and
|
|
308
|
+
// still THROWS on exhaustion so a genuinely-down provider surfaces as
|
|
309
|
+
// operational — never a false 'confirmed'. 404 is NOT retried.
|
|
310
|
+
|
|
311
|
+
describe('gitlabProvenance bounded retry (PROACT-VERIFY-001)', () => {
|
|
312
|
+
it('retries a 429 then confirms on the following 200 (retry worked)', async () => {
|
|
313
|
+
let calls = 0;
|
|
314
|
+
const fetchImpl = async () => {
|
|
315
|
+
calls++;
|
|
316
|
+
if (calls === 1) {
|
|
317
|
+
return { ok: false, status: 429, headers: { get: () => null }, json: async () => ({}) };
|
|
318
|
+
}
|
|
319
|
+
return { ok: true, status: 200, json: async () => mockPipeline() };
|
|
320
|
+
};
|
|
321
|
+
const adapter = gitlabProvenance('token', { timeoutMs: 1000, backoffMs: 1, fetchImpl });
|
|
322
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
323
|
+
assert.equal(ok, true, '429-then-200 must confirm — the retry succeeded');
|
|
324
|
+
assert.equal(calls, 2, 'expected exactly one retry (2 total requests)');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('honors Retry-After (numeric seconds) before the retry', async () => {
|
|
328
|
+
let calls = 0;
|
|
329
|
+
const waited = [];
|
|
330
|
+
const fetchImpl = async () => {
|
|
331
|
+
calls++;
|
|
332
|
+
if (calls === 1) {
|
|
333
|
+
return { ok: false, status: 503, headers: { get: h => (h === 'retry-after' ? '2' : null) }, json: async () => ({}) };
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, status: 200, json: async () => mockPipeline() };
|
|
336
|
+
};
|
|
337
|
+
const adapter = gitlabProvenance('token', {
|
|
338
|
+
timeoutMs: 1000,
|
|
339
|
+
sleepImpl: ms => { waited.push(ms); return Promise.resolve(); },
|
|
340
|
+
fetchImpl
|
|
341
|
+
});
|
|
342
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
343
|
+
assert.equal(ok, true);
|
|
344
|
+
assert.deepEqual(waited, [2000], 'Retry-After: 2 must drive a 2000ms wait');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('THROWS on exhausted 5xx retries (a genuinely-down provider still surfaces)', async () => {
|
|
348
|
+
let calls = 0;
|
|
349
|
+
const fetchImpl = async () => {
|
|
350
|
+
calls++;
|
|
351
|
+
return { ok: false, status: 500, headers: { get: () => null }, json: async () => ({}) };
|
|
352
|
+
};
|
|
353
|
+
const adapter = gitlabProvenance('token', { timeoutMs: 1000, retries: 2, backoffMs: 1, fetchImpl });
|
|
354
|
+
await assert.rejects(
|
|
355
|
+
adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
|
|
356
|
+
err => {
|
|
357
|
+
assert.match(err.message, /provenance: GitLab API returned 500/);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
);
|
|
361
|
+
assert.equal(calls, 3, 'expected 1 initial + 2 retries = 3 requests before throwing');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('does NOT retry a 404 (run genuinely absent → single immediate false)', async () => {
|
|
365
|
+
let calls = 0;
|
|
366
|
+
const fetchImpl = async () => {
|
|
367
|
+
calls++;
|
|
368
|
+
return { ok: false, status: 404, headers: { get: () => null }, json: async () => ({}) };
|
|
369
|
+
};
|
|
370
|
+
const adapter = gitlabProvenance('token', { timeoutMs: 1000, retries: 2, backoffMs: 1, fetchImpl });
|
|
371
|
+
const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
|
|
372
|
+
assert.equal(ok, false);
|
|
373
|
+
assert.equal(calls, 1, '404 must not be retried');
|
|
374
|
+
});
|
|
375
|
+
});
|