@dogfood-lab/verify 1.9.0 → 1.11.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 +19 -16
- package/cli-lint.js +282 -67
- package/cli.js +16 -11
- package/index.js +110 -0
- package/package.json +1 -1
- package/parse-rejection.js +116 -28
- package/validators/f-2a5ddafa-provenance-retry-warn.test.js +226 -0
- package/validators/f-f50e779b-retry-warn-fallback-guard.test.js +153 -0
- package/validators/policy.js +40 -14
- package/validators/provenance-registry.test.js +35 -0
- package/validators/provenance.js +127 -8
- package/validators/repo-binding.js +17 -2
- package/validators/repo-binding.test.js +40 -0
- package/validators/steps.js +77 -0
- package/validators/verdict.js +19 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* f-f50e779b-retry-warn-fallback-guard.test.js
|
|
3
|
+
*
|
|
4
|
+
* F-f50e779b (wave 31, backend amend) — a defect INSIDE the wave-29 fix for
|
|
5
|
+
* F-2a5ddafa, found by this same lane's own wave-30 audit of its own prior
|
|
6
|
+
* work. defaultOnRetryWarn's doc comment states the invariant "the logger
|
|
7
|
+
* must never throw and mask a real retry" and wraps its primary
|
|
8
|
+
* `console.error(JSON.stringify(line))` call in try/catch — but the catch
|
|
9
|
+
* block's OWN recovery action, a second `console.error(...)` call, was not
|
|
10
|
+
* itself guarded. If console.error throws on BOTH calls (a broken stderr fd
|
|
11
|
+
* — EPIPE/ENOSPC, realistic on a CI runner), the second throw escaped
|
|
12
|
+
* defaultOnRetryWarn uncaught, out of the retry loop, out of confirm() —
|
|
13
|
+
* turning a retry that was about to SUCCEED into a rejected promise. Blast
|
|
14
|
+
* radius is bounded by an unrelated, pre-existing mechanism (verify/index.js
|
|
15
|
+
* reclassifies any confirm() throw as a structured PROVENANCE_FAULT, exit 2,
|
|
16
|
+
* nothing persisted) — so this was never a crash, but a submission whose
|
|
17
|
+
* provenance check would have succeeded after a retry was discarded solely
|
|
18
|
+
* because the NEW observability line could not be written: the opposite of
|
|
19
|
+
* what an observability feature should do to reliability.
|
|
20
|
+
*
|
|
21
|
+
* Fix: give the fallback console.error (provenance.js ~line 153-162) its own
|
|
22
|
+
* try/catch, mirroring the two-level guard `dogfood-swarm/lib/log-stage.js`
|
|
23
|
+
* already uses for the identical shape (`catch { try { ... } catch {} }`) —
|
|
24
|
+
* that file was independently swept and confirmed already-hardened; nothing
|
|
25
|
+
* else in this domain shares defaultOnRetryWarn (grepped for
|
|
26
|
+
* onRetryWarn/defaultOnRetryWarn/onWarn/onLog across every owned package —
|
|
27
|
+
* only provenance.js and its two test files match).
|
|
28
|
+
*
|
|
29
|
+
* RED proof — ACTUALLY EXECUTED, not just reasoned (per this wave's brief:
|
|
30
|
+
* "a verification that cannot fail is not a verification"). This exact
|
|
31
|
+
* fixture was run via `node --test` against a byte-identical scratch copy of
|
|
32
|
+
* the PRE-FIX provenance.js in an isolated temp dir (this file has zero
|
|
33
|
+
* imports, so a bare copy is fully self-contained — no workspace needed).
|
|
34
|
+
* Both cases below FAILED against the pre-fix copy, each stack trace pinned
|
|
35
|
+
* to `defaultOnRetryWarn (provenance.js:153)`:
|
|
36
|
+
* - the "resolves true" case rejected with the raw 'EPIPE: simulated
|
|
37
|
+
* broken stderr' error instead of resolving `true`.
|
|
38
|
+
* - the "scope" case rejected with that SAME raw EPIPE error instead of
|
|
39
|
+
* the expected 'provenance: GitHub API timeout' operational error —
|
|
40
|
+
* proving the pre-fix bug also clobbers a genuine exhausted-retry error
|
|
41
|
+
* with the logger's own unrelated failure.
|
|
42
|
+
* The identical fixture was then re-run against a fixed copy (the nested
|
|
43
|
+
* try/catch added, nothing else changed) and both cases passed. Only that
|
|
44
|
+
* one nested try/catch differed between the RED run and the GREEN run.
|
|
45
|
+
*
|
|
46
|
+
* Care taken against the "vacuous test" shape (this wave's own named lesson,
|
|
47
|
+
* re: F-2a5ddafa's original test asserting a fix fired without ever proving
|
|
48
|
+
* it fired ONLY where it should): `opts.onRetryWarn` is deliberately NEVER
|
|
49
|
+
* overridden below — overriding it would bypass defaultOnRetryWarn entirely
|
|
50
|
+
* and the test would pass whether or not this fix exists, regardless of the
|
|
51
|
+
* production code. The global `console.error` (not `process.stderr.write`,
|
|
52
|
+
* which the F-2a5ddafa suite already uses for a *different* assertion) is
|
|
53
|
+
* the monkey-patch target because that is exactly what defaultOnRetryWarn
|
|
54
|
+
* calls. The second test below additionally proves the guard is scoped
|
|
55
|
+
* correctly: it swallows ONLY the logger's own failure and does not mask a
|
|
56
|
+
* real, unrelated operational error the caller is entitled to see.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { describe, it } from 'node:test';
|
|
60
|
+
import assert from 'node:assert/strict';
|
|
61
|
+
|
|
62
|
+
import { githubProvenance } from './provenance.js';
|
|
63
|
+
|
|
64
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
65
|
+
|
|
66
|
+
const GH_SOURCE = {
|
|
67
|
+
provider: 'github',
|
|
68
|
+
workflow: 'dogfood.yml',
|
|
69
|
+
provider_run_id: '9123456789',
|
|
70
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function abortError() {
|
|
74
|
+
const e = new Error('This operation was aborted');
|
|
75
|
+
e.name = 'AbortError';
|
|
76
|
+
return e;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const noSleep = async () => {};
|
|
80
|
+
|
|
81
|
+
function okGithubResp() {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
status: 200,
|
|
85
|
+
json: async () => ({
|
|
86
|
+
id: 9123456789,
|
|
87
|
+
status: 'completed',
|
|
88
|
+
head_sha: RUN_HEAD,
|
|
89
|
+
repository: { full_name: 'acme/widget' }
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Monkey-patch the GLOBAL console.error for the duration of `fn` so every
|
|
96
|
+
* call throws, simulating a broken stderr fd (EPIPE/ENOSPC) — the exact
|
|
97
|
+
* mechanism the finding proved live. Always restores the original in
|
|
98
|
+
* `finally` so a failing assertion cannot leak the patch into a sibling
|
|
99
|
+
* test (node:test runs this file's cases sequentially, but a leaked patch
|
|
100
|
+
* would still poison any later suite in the same process).
|
|
101
|
+
*
|
|
102
|
+
* @param {(getCallCount: () => number) => Promise<void>} fn
|
|
103
|
+
*/
|
|
104
|
+
async function withThrowingConsoleError(fn) {
|
|
105
|
+
const orig = console.error;
|
|
106
|
+
let calls = 0;
|
|
107
|
+
console.error = () => {
|
|
108
|
+
calls++;
|
|
109
|
+
throw new Error('EPIPE: simulated broken stderr');
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await fn(() => calls);
|
|
113
|
+
} finally {
|
|
114
|
+
console.error = orig;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @pins F-f50e779b */
|
|
119
|
+
describe('F-f50e779b — defaultOnRetryWarn fallback is itself guarded', () => {
|
|
120
|
+
it('a retry that would succeed still resolves true when console.error throws on every call', async () => {
|
|
121
|
+
await withThrowingConsoleError(async (getCalls) => {
|
|
122
|
+
let fetchCalls = 0;
|
|
123
|
+
const adapter = githubProvenance('token', {
|
|
124
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
125
|
+
// Deliberately NOT overriding onRetryWarn — must exercise the real
|
|
126
|
+
// defaultOnRetryWarn, or this test never reaches the guarded code
|
|
127
|
+
// (the exact vacuous-test shape this suite's header warns against).
|
|
128
|
+
fetchImpl: async () => {
|
|
129
|
+
fetchCalls++;
|
|
130
|
+
if (fetchCalls === 1) throw abortError();
|
|
131
|
+
return okGithubResp();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
135
|
+
assert.equal(ok, true, 'a retry that succeeds must resolve true, not reject');
|
|
136
|
+
assert.equal(getCalls(), 2, 'expected exactly 2 console.error attempts: primary (line ~149) + fallback (line ~153)');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('scope: a genuinely exhausted retry budget still throws the real operational error, not the logger error', async () => {
|
|
141
|
+
await withThrowingConsoleError(async () => {
|
|
142
|
+
const adapter = githubProvenance('token', {
|
|
143
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
144
|
+
fetchImpl: async () => { throw abortError(); } // never succeeds — budget exhausts
|
|
145
|
+
});
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
() => adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD }),
|
|
148
|
+
/provenance: GitHub API timeout/,
|
|
149
|
+
'the fallback guard must swallow ONLY the logger failure — it must never mask the real exhausted-retry error behind it'
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
package/validators/policy.js
CHANGED
|
@@ -118,6 +118,15 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
|
|
|
118
118
|
* `configErrors` (VERIFY-F1) are eval-time repo custom-rule predicate faults; the
|
|
119
119
|
* caller emits them with a `policy-config:` prefix (submission-bad). A non-empty
|
|
120
120
|
* `configErrors` makes `valid` false.
|
|
121
|
+
* @throws {Error} F-3ef6b03e: a global rule declared `severity: reject` whose id
|
|
122
|
+
* is outside {@link KNOWN_REJECT_RULE_IDS} — a maintainer-authored
|
|
123
|
+
* global-policy.yaml gap, not a submission fault. Mirrors the GLOBAL-rule
|
|
124
|
+
* predicate-fault throw above: `runValidator('policy', ...)` in index.js
|
|
125
|
+
* wraps this as `VALIDATOR_FAULT_POLICY:`, which parseRejectionReason
|
|
126
|
+
* classifies 'operational' by family match. Pre-fix this pushed into
|
|
127
|
+
* `errors` under the `policy:` prefix, which parseRejectionReason
|
|
128
|
+
* classifies 'submission-bad' — telling every consumer in the fleet to fix
|
|
129
|
+
* a payload that was never the problem.
|
|
121
130
|
*/
|
|
122
131
|
export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
123
132
|
const errors = [];
|
|
@@ -196,15 +205,21 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
196
205
|
|
|
197
206
|
// schema-valid, provenance-confirmed, step-results-present, step-verdict-consistent,
|
|
198
207
|
// no-verdict-upgrade are enforced by other validators or the main verify() function.
|
|
199
|
-
// PROACT-VERIFY-002: a reject rule the build neither handles here NOR
|
|
200
|
-
// elsewhere would otherwise pass SILENTLY — the operator's new gate never
|
|
201
|
-
//
|
|
202
|
-
//
|
|
208
|
+
// PROACT-VERIFY-002 / F-3ef6b03e: a reject rule the build neither handles here NOR
|
|
209
|
+
// enforces elsewhere would otherwise pass SILENTLY — the operator's new gate never
|
|
210
|
+
// runs. THROW rather than push to `errors`: this is a maintainer-authored
|
|
211
|
+
// global-policy.yaml gap (the same class of broken-policy-file fault
|
|
212
|
+
// loadGlobalPolicy's schema gate already fails loud on), not a submission fault.
|
|
213
|
+
// Pushing to `errors` would route through the `policy:` prefix — classified
|
|
214
|
+
// 'submission-bad' by parseRejectionReason — telling the submitter to fix a
|
|
215
|
+
// payload that was never the problem, fleet-wide, for every submission until a
|
|
216
|
+
// maintainer notices. Throwing here mirrors the GLOBAL-rule predicate-fault
|
|
217
|
+
// throw above: runValidator('policy', ...) in index.js wraps it as
|
|
218
|
+
// `VALIDATOR_FAULT_POLICY:`, classified 'operational' — exit 2, nothing
|
|
219
|
+
// persisted, no run_id poisoned via the duplicate guard (F-82429f90).
|
|
203
220
|
default:
|
|
204
221
|
if (!KNOWN_REJECT_RULE_IDS.has(rule.id)) {
|
|
205
|
-
|
|
206
|
-
// error (mirrors the `[rule.id]` / `surface[...]` messages above).
|
|
207
|
-
errors.push(
|
|
222
|
+
throw new Error(
|
|
208
223
|
`rule "${rule.id}" is declared severity:reject but has no enforcement in this build — ` +
|
|
209
224
|
`add an enforcement arm in validators/policy.js or register it in KNOWN_REJECT_RULE_IDS`
|
|
210
225
|
);
|
|
@@ -299,13 +314,24 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
|
|
|
299
314
|
const ciReqs = surfacePolicy.ci_requirements;
|
|
300
315
|
if (!ciReqs) continue;
|
|
301
316
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
317
|
+
// F-3b34d51e: mirror coverage_min's missing-data contract. Pre-fix the
|
|
318
|
+
// gate was `if (tests_must_pass && submission.ci_checks)` — omitting the
|
|
319
|
+
// optional ci_checks field (or sending [] / no kind:'test' entries)
|
|
320
|
+
// skipped the branch entirely and policy-validated clean under a
|
|
321
|
+
// tests_must_pass:true surface. Require at least one kind:'test' check
|
|
322
|
+
// whose status is not 'fail'; reject absent/empty/no-test-kind evidence.
|
|
323
|
+
if (ciReqs.tests_must_pass) {
|
|
324
|
+
const testChecks = (submission.ci_checks || []).filter(c => c.kind === 'test');
|
|
325
|
+
if (testChecks.length === 0) {
|
|
326
|
+
errors.push(
|
|
327
|
+
`surface[${surface}]: tests_must_pass is true but no kind:test CI check provided`
|
|
328
|
+
);
|
|
329
|
+
} else {
|
|
330
|
+
const failingTests = testChecks.filter(c => c.status === 'fail');
|
|
331
|
+
if (failingTests.length > 0) {
|
|
332
|
+
const ids = failingTests.map(c => c.id).join(', ');
|
|
333
|
+
errors.push(`surface[${surface}]: CI tests must pass but [${ids}] failed`);
|
|
334
|
+
}
|
|
309
335
|
}
|
|
310
336
|
}
|
|
311
337
|
|
|
@@ -25,6 +25,22 @@ const schemaPath = require.resolve(
|
|
|
25
25
|
const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
26
26
|
const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* F-a2fb624f: the pre-fix tripwire (`provenanceForProvider('bitbucket') ===
|
|
30
|
+
* null`) only probed a NON-prototype unknown key — green both before AND
|
|
31
|
+
* after F-2965699b's fix, so it gave zero coverage for the actual defect
|
|
32
|
+
* class. This table-driven set is the real regression guard: every
|
|
33
|
+
* Object.prototype own-property name, which is exactly the key space
|
|
34
|
+
* `PROVENANCE_ADAPTERS[provider]` resolved (as a truthy inherited method,
|
|
35
|
+
* defeating `?? null`) before the Object.hasOwn fix. Shared verbatim with
|
|
36
|
+
* repo-binding.test.js's sibling table so both prototype-safety sites are
|
|
37
|
+
* proven against the identical key set.
|
|
38
|
+
*/
|
|
39
|
+
const OBJECT_PROTOTYPE_KEYS = [
|
|
40
|
+
'constructor', 'toString', 'valueOf', '__proto__',
|
|
41
|
+
'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
|
|
42
|
+
];
|
|
43
|
+
|
|
28
44
|
describe('provenance adapter coverage', () => {
|
|
29
45
|
it('exposes the provider enum it is meant to cover (sanity)', () => {
|
|
30
46
|
assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
|
|
@@ -53,4 +69,23 @@ describe('provenance adapter coverage', () => {
|
|
|
53
69
|
it('provenanceForProvider returns null for an unknown provider (no throw)', () => {
|
|
54
70
|
assert.equal(provenanceForProvider('bitbucket'), null);
|
|
55
71
|
});
|
|
72
|
+
|
|
73
|
+
describe('F-2965699b: provenanceForProvider never resolves an inherited Object.prototype key', () => {
|
|
74
|
+
for (const key of OBJECT_PROTOTYPE_KEYS) {
|
|
75
|
+
it(`returns null (not the inherited method) for provider="${key}"`, () => {
|
|
76
|
+
// Deletion/emptiness proof: revert provenanceForProvider to
|
|
77
|
+
// `PROVENANCE_ADAPTERS[provider] ?? null` and this goes red for
|
|
78
|
+
// every key here — each one currently resolves a truthy inherited
|
|
79
|
+
// Object.prototype value that `?? null` cannot catch.
|
|
80
|
+
assert.equal(provenanceForProvider(key), null,
|
|
81
|
+
`provider="${key}" must resolve to null, not an inherited Object.prototype member`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
|
|
86
|
+
for (const bad of [{}, 42, null, undefined, ['github']]) {
|
|
87
|
+
assert.equal(provenanceForProvider(bad), null, `provider=${JSON.stringify(bad)} must resolve to null`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
56
91
|
});
|
package/validators/provenance.js
CHANGED
|
@@ -14,6 +14,24 @@
|
|
|
14
14
|
* `source.provider`, so the provider enum, the run_url parsers
|
|
15
15
|
* (validators/repo-binding.js), and the adapters here stay in lockstep —
|
|
16
16
|
* coverage tests fail CI if any of the three drifts.
|
|
17
|
+
*
|
|
18
|
+
* F-936c83f4 (wave 39, feature-pass integration finding): this module's
|
|
19
|
+
* `--provenance=stub|github` (the flag both packages/verify/cli.js and
|
|
20
|
+
* packages/ingest/run.js parse, both delegating to the adapters here) answers
|
|
21
|
+
* ONE of three structurally-similar-but-distinct 'how do we know this claim is
|
|
22
|
+
* true' questions living in this repo: (1) THIS module — does a submitted CI
|
|
23
|
+
* run really exist and match its claims; (2) dogfood-finding.schema.json's
|
|
24
|
+
* `evidence[].evidence_kind` enum — what evidence backs a distilled cross-repo
|
|
25
|
+
* lesson (see that property's own cross-reference note); (3) the swarm
|
|
26
|
+
* control-plane's incoming `verified_how`
|
|
27
|
+
* (independent|self_attested|operator_evidence,
|
|
28
|
+
* docs/trajectory-and-closure.dispatch.md's C2 `swarm close` verb) — how a
|
|
29
|
+
* finding CLOSURE was verified. This is NOT a request to unify the three
|
|
30
|
+
* enums — CI-run provenance, cross-repo-lesson evidence, and
|
|
31
|
+
* closure-verification-method are genuinely different axes, and forcing one
|
|
32
|
+
* shared enum would misrepresent that. The pointer exists only so the next
|
|
33
|
+
* reader of any of the three doesn't rediscover the pattern from scratch a
|
|
34
|
+
* fourth time.
|
|
17
35
|
*/
|
|
18
36
|
|
|
19
37
|
/**
|
|
@@ -105,6 +123,67 @@ function defaultSleep(ms) {
|
|
|
105
123
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
/**
|
|
127
|
+
* F-2a5ddafa: emit ONE structured NDJSON warn line to stderr each time a
|
|
128
|
+
* retry is about to happen, so an operator tailing CI logs sees a degrading
|
|
129
|
+
* provider BEFORE the retry budget exhausts and throws. Pre-fix, every
|
|
130
|
+
* `await sleep(...); continue;` branch in both adapters below was completely
|
|
131
|
+
* silent — a submission that succeeded only after 1-2 retries against a
|
|
132
|
+
* degrading GitHub/GitLab API was byte-for-byte indistinguishable in the log
|
|
133
|
+
* from one that succeeded on the first try. The eventual exhausted-retry
|
|
134
|
+
* failure is already loud (an operational throw); this closes the gap on
|
|
135
|
+
* the way there, which is exactly the window an operator could act in
|
|
136
|
+
* (e.g. correlate a slow ingest with a known provider incident).
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT named `logStage` and NOT importing
|
|
139
|
+
* `@dogfood-lab/dogfood-swarm/lib/log-stage.js`: packages/verify depends on
|
|
140
|
+
* nothing but `@dogfood-lab/schemas` + `js-yaml` today (see package.json),
|
|
141
|
+
* and the accepted workspace cycle (root CLAUDE.md, "Workspace dependency
|
|
142
|
+
* graph") is `findings -> ingest -> dogfood-swarm -> findings`. `ingest`
|
|
143
|
+
* ALSO depends on `verify`, so a NEW `verify -> dogfood-swarm` edge would
|
|
144
|
+
* not reuse that cycle — it would close a SECOND, larger one:
|
|
145
|
+
* `verify -> dogfood-swarm -> findings -> ingest -> verify`. This mirrors
|
|
146
|
+
* the repo's existing precedent for exactly this constraint —
|
|
147
|
+
* `packages/ingest/lib/sleep-sync.js` duplicates
|
|
148
|
+
* `packages/findings/lib/file-lock.js`'s private `sleepSync` verbatim,
|
|
149
|
+
* per that file's own header, rather than take a disallowed cross-edge —
|
|
150
|
+
* so this emits the same NDJSON stage-line SHAPE independently instead of
|
|
151
|
+
* importing the shared helper.
|
|
152
|
+
* `packages/ingest/wave22-log-stage-discipline.test.js` (Class #9 sweep)
|
|
153
|
+
* enforces that no file under `packages/**` defines its OWN `logStage`
|
|
154
|
+
* without delegating to the shared helper; this helper uses a distinct
|
|
155
|
+
* name for exactly that reason and is not a competing definition of that
|
|
156
|
+
* convention.
|
|
157
|
+
*
|
|
158
|
+
* Injectable via `opts.onRetryWarn` (mirrors the already-injectable
|
|
159
|
+
* `opts.fetchImpl` / `opts.sleepImpl` on this file) so tests assert the
|
|
160
|
+
* exact fields without scraping stderr.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ kind: string, provider: string, attempt: number, status_or_reason: string|number, next_backoff_ms: number }} fields
|
|
163
|
+
*/
|
|
164
|
+
function defaultOnRetryWarn(fields) {
|
|
165
|
+
const line = { ts: new Date().toISOString(), component: 'verify', stage: 'warn', ...fields };
|
|
166
|
+
try {
|
|
167
|
+
console.error(JSON.stringify(line));
|
|
168
|
+
} catch {
|
|
169
|
+
// The logger must never throw and mask a real retry — same last-resort
|
|
170
|
+
// discipline as the shared dogfood-swarm helper (log-stage.js). F-f50e779b:
|
|
171
|
+
// this fallback itself must not throw either — a broken stderr fd
|
|
172
|
+
// (EPIPE/ENOSPC) fails BOTH console.error calls, and pre-fix this second
|
|
173
|
+
// call was unguarded, so its throw escaped defaultOnRetryWarn uncaught and
|
|
174
|
+
// turned a retry that was about to SUCCEED into a rejected confirm() (the
|
|
175
|
+
// observability feature converting a good outcome into a discarded
|
|
176
|
+
// submission — see log-stage.js's own two-level guard for the same shape,
|
|
177
|
+
// which this now mirrors exactly).
|
|
178
|
+
try {
|
|
179
|
+
console.error('{"stage":"warn","kind":"log_serialization_failed"}');
|
|
180
|
+
} catch {
|
|
181
|
+
// stderr itself is broken; nothing further to do, but must not crash
|
|
182
|
+
// the caller — there is no third fallback to hand the failure to.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
108
187
|
/**
|
|
109
188
|
* Stub provenance adapter. Always confirms.
|
|
110
189
|
* Use in tests and local development.
|
|
@@ -139,6 +218,7 @@ export function githubProvenance(token, opts = {}) {
|
|
|
139
218
|
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
140
219
|
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
141
220
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
221
|
+
const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
|
|
142
222
|
return {
|
|
143
223
|
/**
|
|
144
224
|
* @param {object} source - submission.source (provider-scoped run claim)
|
|
@@ -204,7 +284,11 @@ export function githubProvenance(token, opts = {}) {
|
|
|
204
284
|
// transport-reject branch below (mirrors the scenario fetcher's
|
|
205
285
|
// retryable-timeout discipline), then throw on exhaustion.
|
|
206
286
|
if (attempt < retries) {
|
|
207
|
-
|
|
287
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
288
|
+
// F-2a5ddafa: visibility BEFORE the budget exhausts (see
|
|
289
|
+
// defaultOnRetryWarn's doc above for why this is 'warn').
|
|
290
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
|
|
291
|
+
await sleep(waitMs);
|
|
208
292
|
continue;
|
|
209
293
|
}
|
|
210
294
|
throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
|
|
@@ -218,7 +302,9 @@ export function githubProvenance(token, opts = {}) {
|
|
|
218
302
|
// and the duplicate guard then blocked a clean resubmission under the
|
|
219
303
|
// same run_id. `return false` is reserved for HTTP 404 (mirrors GitLab).
|
|
220
304
|
if (attempt < retries) {
|
|
221
|
-
|
|
305
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
306
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
|
|
307
|
+
await sleep(waitMs);
|
|
222
308
|
continue;
|
|
223
309
|
}
|
|
224
310
|
throw new Error(`provenance: network error: ${err.message}`);
|
|
@@ -238,7 +324,9 @@ export function githubProvenance(token, opts = {}) {
|
|
|
238
324
|
// last 429/5xx throws so a real outage still surfaces (operational).
|
|
239
325
|
if (resp.status === 404) return false;
|
|
240
326
|
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
241
|
-
|
|
327
|
+
const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
|
|
328
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'github', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
|
|
329
|
+
await sleep(waitMs);
|
|
242
330
|
continue;
|
|
243
331
|
}
|
|
244
332
|
throw new Error(`provenance: GitHub API returned ${resp.status}`);
|
|
@@ -312,6 +400,7 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
312
400
|
const retries = opts.retries ?? PROVENANCE_RETRIES;
|
|
313
401
|
const backoffMs = opts.backoffMs ?? PROVENANCE_BACKOFF_MS;
|
|
314
402
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
403
|
+
const onRetryWarn = opts.onRetryWarn ?? defaultOnRetryWarn;
|
|
315
404
|
return {
|
|
316
405
|
/**
|
|
317
406
|
* @param {object} source - submission.source (provider-scoped run claim)
|
|
@@ -382,7 +471,9 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
382
471
|
// F-8e72d0de: retry timeouts within the shared budget — peer
|
|
383
472
|
// discipline with githubProvenance (see that adapter's note).
|
|
384
473
|
if (attempt < retries) {
|
|
385
|
-
|
|
474
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
475
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: 'timeout', next_backoff_ms: waitMs });
|
|
476
|
+
await sleep(waitMs);
|
|
386
477
|
continue;
|
|
387
478
|
}
|
|
388
479
|
throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
|
|
@@ -390,7 +481,9 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
390
481
|
// F-dac7e08c: transport reject = operational, retried then thrown —
|
|
391
482
|
// mirrors githubProvenance exactly. `return false` is 404-only.
|
|
392
483
|
if (attempt < retries) {
|
|
393
|
-
|
|
484
|
+
const waitMs = nextBackoffMs(null, attempt + 1, backoffMs);
|
|
485
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: err && err.message ? err.message : 'network_error', next_backoff_ms: waitMs });
|
|
486
|
+
await sleep(waitMs);
|
|
394
487
|
continue;
|
|
395
488
|
}
|
|
396
489
|
throw new Error(`provenance: network error: ${err.message}`);
|
|
@@ -408,7 +501,9 @@ export function gitlabProvenance(token, opts = {}) {
|
|
|
408
501
|
// budget. 401/403 = non-transient operational, throw immediately.
|
|
409
502
|
if (resp.status === 404) return false;
|
|
410
503
|
if (isRetryableStatus(resp.status) && attempt < retries) {
|
|
411
|
-
|
|
504
|
+
const waitMs = nextBackoffMs(resp, attempt + 1, backoffMs);
|
|
505
|
+
onRetryWarn({ kind: 'provenance_retry', provider: 'gitlab', attempt: attempt + 1, status_or_reason: resp.status, next_backoff_ms: waitMs });
|
|
506
|
+
await sleep(waitMs);
|
|
412
507
|
continue;
|
|
413
508
|
}
|
|
414
509
|
throw new Error(`provenance: GitLab API returned ${resp.status}`);
|
|
@@ -459,10 +554,34 @@ export const PROVENANCE_ADAPTERS = {
|
|
|
459
554
|
/**
|
|
460
555
|
* Resolve the provenance-adapter factory for a `source.provider`.
|
|
461
556
|
*
|
|
557
|
+
* F-2965699b: `PROVENANCE_ADAPTERS[provider]` alone resolves inherited
|
|
558
|
+
* `Object.prototype` keys (`provider: 'valueOf'` returns
|
|
559
|
+
* `Object.prototype.valueOf`, a truthy function) — the `??` operator only
|
|
560
|
+
* catches `null`/`undefined`, so the `if (!factory)` unknown-provider gate at
|
|
561
|
+
* this function's ONE caller (`resolveProviderProvenance`, packages/ingest/
|
|
562
|
+
* run.js) never fires for the whole Object.prototype key space, and
|
|
563
|
+
* `factory(token)` goes on to call an unrelated Object.prototype method with
|
|
564
|
+
* an unbound `this`, producing an uncaught TypeError (`valueOf`/`__proto__`)
|
|
565
|
+
* or a misleading downstream error (`constructor`/`toString`/
|
|
566
|
+
* `isPrototypeOf`) instead of the correct "unknown provenance provider"
|
|
567
|
+
* message. This site is reached on FULLY UNTRUSTED input — `submission.
|
|
568
|
+
* source.provider` — BEFORE verify()'s schema gate constrains it to the
|
|
569
|
+
* [github, gitlab] enum.
|
|
570
|
+
*
|
|
571
|
+
* `Object.hasOwn` restricts the lookup to the registry's own properties,
|
|
572
|
+
* matching the idiom `safeGet()` already uses in validators/predicate.js —
|
|
573
|
+
* the two prototype-safety sites now read the same.
|
|
574
|
+
*
|
|
462
575
|
* @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
|
|
463
576
|
* @returns {((token: string, opts?: object) => { confirm: Function }) | null}
|
|
464
|
-
* The adapter factory, or `null` when the provider has no registered adapter
|
|
577
|
+
* The adapter factory, or `null` when the provider has no registered adapter
|
|
578
|
+
* (including every Object.prototype key — `constructor`, `toString`,
|
|
579
|
+
* `valueOf`, `__proto__`, `hasOwnProperty`, `isPrototypeOf`,
|
|
580
|
+
* `propertyIsEnumerable`, `toLocaleString` — none of which are OWN
|
|
581
|
+
* properties of PROVENANCE_ADAPTERS).
|
|
465
582
|
*/
|
|
466
583
|
export function provenanceForProvider(provider) {
|
|
467
|
-
return
|
|
584
|
+
return typeof provider === 'string' && Object.hasOwn(PROVENANCE_ADAPTERS, provider)
|
|
585
|
+
? PROVENANCE_ADAPTERS[provider]
|
|
586
|
+
: null;
|
|
468
587
|
}
|
|
@@ -88,13 +88,28 @@ export const RUN_URL_PARSERS = {
|
|
|
88
88
|
/**
|
|
89
89
|
* Decode the owner/repo a run_url attests to, for the given provider.
|
|
90
90
|
*
|
|
91
|
+
* F-7ce07baa: family sibling of F-2965699b (validators/provenance.js). Plain
|
|
92
|
+
* bracket access resolves inherited Object.prototype keys, so
|
|
93
|
+
* `RUN_URL_PARSERS[provider]` for `provider: '__proto__'` (etc.) returns a
|
|
94
|
+
* truthy Object.prototype method instead of `undefined` — the `if (!parser)`
|
|
95
|
+
* unknown-provider gate cannot fire for that key space, and `parser(runUrl)`
|
|
96
|
+
* goes on to call an unrelated Object.prototype method. This site is reached
|
|
97
|
+
* even EARLIER in the trust chain than provenanceForProvider: verify/
|
|
98
|
+
* index.js's repo-binding guard runs at step 0, before the schema gate, so
|
|
99
|
+
* `submission.source.provider` is raw untrusted input here too.
|
|
100
|
+
* `Object.hasOwn` matches the safeGet()/provenanceForProvider idiom so every
|
|
101
|
+
* prototype-safety site in this codebase reads the same.
|
|
102
|
+
*
|
|
91
103
|
* @param {string} provider The `source.provider` token (e.g. 'github').
|
|
92
104
|
* @param {string} runUrl The `source.run_url`.
|
|
93
105
|
* @returns {RunUrlRepo | null} `{ owner, repo }`, or `null` when the provider
|
|
94
|
-
* has no parser or the URL does not
|
|
106
|
+
* has no parser (including every Object.prototype key) or the URL does not
|
|
107
|
+
* match the provider's shape.
|
|
95
108
|
*/
|
|
96
109
|
export function parseRunUrlRepo(provider, runUrl) {
|
|
97
|
-
const parser = RUN_URL_PARSERS
|
|
110
|
+
const parser = typeof provider === 'string' && Object.hasOwn(RUN_URL_PARSERS, provider)
|
|
111
|
+
? RUN_URL_PARSERS[provider]
|
|
112
|
+
: null;
|
|
98
113
|
if (!parser) return null;
|
|
99
114
|
return parser(runUrl);
|
|
100
115
|
}
|
|
@@ -29,6 +29,19 @@ const schemaPath = require.resolve(
|
|
|
29
29
|
const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
30
30
|
const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* F-a2fb624f: sibling table to provenance-registry.test.js's — the pre-fix
|
|
34
|
+
* tripwire here ('returns null for a provider with no parser') only probed
|
|
35
|
+
* 'bitbucket', a NON-prototype key, so it gave zero coverage for the actual
|
|
36
|
+
* F-7ce07baa defect class (every Object.prototype own-property name, which
|
|
37
|
+
* `RUN_URL_PARSERS[provider]` resolved as a truthy inherited method before
|
|
38
|
+
* the Object.hasOwn fix).
|
|
39
|
+
*/
|
|
40
|
+
const OBJECT_PROTOTYPE_KEYS = [
|
|
41
|
+
'constructor', 'toString', 'valueOf', '__proto__',
|
|
42
|
+
'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toLocaleString',
|
|
43
|
+
];
|
|
44
|
+
|
|
32
45
|
describe('repo-binding guard coverage (verify-B-001)', () => {
|
|
33
46
|
it('exposes the provider enum it is meant to cover (sanity)', () => {
|
|
34
47
|
assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
|
|
@@ -115,4 +128,31 @@ describe('repo-binding guard coverage (verify-B-001)', () => {
|
|
|
115
128
|
it('returns null for a provider with no parser (no throw)', () => {
|
|
116
129
|
assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
|
|
117
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('F-7ce07baa: parseRunUrlRepo never resolves an inherited Object.prototype key', () => {
|
|
133
|
+
for (const key of OBJECT_PROTOTYPE_KEYS) {
|
|
134
|
+
it(`returns null (not the inherited method) for provider="${key}"`, () => {
|
|
135
|
+
// Deletion/emptiness proof: revert parseRunUrlRepo to a bare
|
|
136
|
+
// `RUN_URL_PARSERS[provider]` lookup and this goes red for every key
|
|
137
|
+
// here — each currently resolves a truthy inherited Object.prototype
|
|
138
|
+
// value the `if (!parser)` guard cannot catch, and `parser(runUrl)`
|
|
139
|
+
// then calls an unrelated built-in method.
|
|
140
|
+
assert.equal(
|
|
141
|
+
parseRunUrlRepo(key, 'https://github.com/acme/widget/actions/runs/1'),
|
|
142
|
+
null,
|
|
143
|
+
`provider="${key}" must resolve to null, not an inherited Object.prototype member`
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('a non-string provider (object, number, null, undefined) never throws and returns null', () => {
|
|
149
|
+
for (const bad of [{}, 42, null, undefined, ['github']]) {
|
|
150
|
+
assert.equal(
|
|
151
|
+
parseRunUrlRepo(bad, 'https://github.com/acme/widget/actions/runs/1'),
|
|
152
|
+
null,
|
|
153
|
+
`provider=${JSON.stringify(bad)} must resolve to null`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
118
158
|
});
|