@dogfood-lab/ingest 1.8.0 → 1.10.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/lib/integrity.js +15 -1
- package/lib/unsafe-segment.js +15 -7
- package/load-context.js +344 -55
- package/package.json +1 -1
- package/persist.js +250 -5
- package/rebuild-indexes.js +18 -2
- package/run.js +525 -44
- package/verify-chain.js +253 -17
package/lib/integrity.js
CHANGED
|
@@ -62,13 +62,27 @@ export function canonicalize(value) {
|
|
|
62
62
|
/**
|
|
63
63
|
* Deep clone with object keys sorted ascending at every depth. Arrays keep
|
|
64
64
|
* order (only their element objects are key-sorted). Primitives pass through.
|
|
65
|
+
*
|
|
66
|
+
* F-755d0f3f: the accumulator MUST be null-prototype. On a plain `{}` the
|
|
67
|
+
* assignment below would hit the inherited `Object.prototype.__proto__` setter
|
|
68
|
+
* for an own `__proto__` key and retarget the accumulator's prototype instead of
|
|
69
|
+
* creating an own key — dropping that field, and everything under it, from the
|
|
70
|
+
* canonical string and therefore from the record digest. `JSON.parse` creates a
|
|
71
|
+
* real own `__proto__` data property (CreateDataProperty, not Set), so any
|
|
72
|
+
* record file on disk can carry one, and verify-chain.js reaches this via a bare
|
|
73
|
+
* `JSON.parse` with no schema gate. That made two materially different records
|
|
74
|
+
* hash identically and let a tampered record re-verify clean.
|
|
75
|
+
*
|
|
76
|
+
* `JSON.stringify` serializes a null-prototype object identically to a plain
|
|
77
|
+
* one, so this is byte-for-byte compatible with every already-persisted record
|
|
78
|
+
* and the committed chain is unaffected.
|
|
65
79
|
*/
|
|
66
80
|
function sortDeep(value) {
|
|
67
81
|
if (Array.isArray(value)) {
|
|
68
82
|
return value.map(sortDeep);
|
|
69
83
|
}
|
|
70
84
|
if (value !== null && typeof value === 'object') {
|
|
71
|
-
const sorted =
|
|
85
|
+
const sorted = Object.create(null);
|
|
72
86
|
for (const key of Object.keys(value).sort()) {
|
|
73
87
|
sorted[key] = sortDeep(value[key]);
|
|
74
88
|
}
|
package/lib/unsafe-segment.js
CHANGED
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
* - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
|
|
7
7
|
* - packages/findings/derive/load-records.js (the missing third callsite)
|
|
8
8
|
*
|
|
9
|
-
* The check rejects path-traversal substrings (`..`)
|
|
10
|
-
* (`/`, `\`)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The check rejects path-traversal substrings (`..`), any path separator
|
|
10
|
+
* (`/`, `\`), and a segment that is EXACTLY `.` (F-3d2e6edf: path.join
|
|
11
|
+
* normalizes a lone-dot segment away, so a schema-valid repo like `./x`
|
|
12
|
+
* filed records one directory level UP — records/_rejected/x/... at the org
|
|
13
|
+
* level — and resolved policies/repos/./x.yaml to policies/repos/x.yaml;
|
|
14
|
+
* the verify CLI's safe() helper already blocked `.`, and the two guards
|
|
15
|
+
* must agree). EMBEDDED dots remain legal because GitHub permits dotted
|
|
16
|
+
* org/repo names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The
|
|
17
|
+
* submission schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees
|
|
18
|
+
* on characters (though it still admits a lone-dot segment — this guard is
|
|
19
|
+
* the backstop).
|
|
13
20
|
*
|
|
14
21
|
* F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
|
|
15
|
-
* legitimate submissions inside writeRecord. The narrower
|
|
16
|
-
* stood since wave 9; this helper is the
|
|
22
|
+
* legitimate submissions inside writeRecord. The narrower form (now
|
|
23
|
+
* `/^\.$|\.\.|[/\\]/`) has stood since wave 9; this helper is the
|
|
24
|
+
* productized form.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
/**
|
|
@@ -22,7 +30,7 @@
|
|
|
22
30
|
*
|
|
23
31
|
* @type {RegExp}
|
|
24
32
|
*/
|
|
25
|
-
export const UNSAFE_SEGMENT =
|
|
33
|
+
export const UNSAFE_SEGMENT = /^\.$|\.\.|[/\\]/;
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* Predicate form: returns true when the given segment contains a path-traversal
|
package/load-context.js
CHANGED
|
@@ -129,7 +129,14 @@ export function loadGlobalPolicy(repoRoot) {
|
|
|
129
129
|
* @returns {object|null|{ __torn: true, reason: string, path: string }}
|
|
130
130
|
*/
|
|
131
131
|
export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
132
|
-
|
|
132
|
+
// F-54e5fde7: the submission contract is strictly two-segment (the schema's
|
|
133
|
+
// `repo` pattern forbids a second slash; nested GitLab subgroups are
|
|
134
|
+
// UNSUPPORTED). A 3+-segment slug must fail closed here — the old 2-way
|
|
135
|
+
// destructure silently dropped the third segment and would have resolved a
|
|
136
|
+
// DIFFERENT repo's policy file (group/subgroup/project → repos/group/subgroup.yaml).
|
|
137
|
+
const segments = repoSlug.split('/');
|
|
138
|
+
if (segments.length !== 2) return null;
|
|
139
|
+
const [org, repo] = segments;
|
|
133
140
|
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
|
|
134
141
|
const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
|
|
135
142
|
|
|
@@ -173,24 +180,6 @@ export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
|
173
180
|
return parsed;
|
|
174
181
|
}
|
|
175
182
|
|
|
176
|
-
/**
|
|
177
|
-
* Default scenario fetcher that reads from the local filesystem.
|
|
178
|
-
* Used when dogfood-labs is dogfooding itself.
|
|
179
|
-
*
|
|
180
|
-
* @param {string} repoRoot - Root of the source repo
|
|
181
|
-
* @returns {object} Scenario fetch adapter
|
|
182
|
-
*/
|
|
183
|
-
export function localScenarioFetcher(repoRoot) {
|
|
184
|
-
return {
|
|
185
|
-
async fetch(scenarioId) {
|
|
186
|
-
if (!/^[\w-]+$/.test(scenarioId)) return null;
|
|
187
|
-
const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
|
|
188
|
-
if (!existsSync(path)) return null;
|
|
189
|
-
return yaml.load(readFileSync(path, 'utf-8'));
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
183
|
/**
|
|
195
184
|
* Default per-request timeout for the GitHub scenario fetch. Mirrors the
|
|
196
185
|
* sibling `GITHUB_PROVENANCE_TIMEOUT_MS` constant at
|
|
@@ -215,11 +204,113 @@ export const GITHUB_SCENARIO_FETCH_ATTEMPTS = 3;
|
|
|
215
204
|
const RETRY_BASE_MS = 250;
|
|
216
205
|
const RETRY_MAX_MS = 2000;
|
|
217
206
|
|
|
207
|
+
/**
|
|
208
|
+
* V2-CROSS-BO-002: byte cap on a fetched scenario body. A scenario definition
|
|
209
|
+
* crosses the consumer-repo → verifier trust boundary; without a cap a
|
|
210
|
+
* hostile/misconfigured source repo could stream an arbitrarily large body
|
|
211
|
+
* into memory and into yaml.load. Real scenario files are a few KB — 1 MiB is
|
|
212
|
+
* three orders of magnitude of headroom. Checked against Content-Length
|
|
213
|
+
* BEFORE the body is read (when the response exposes it), then enforced
|
|
214
|
+
* WHILE the body streams (F-35486d38: the reader aborts the moment the
|
|
215
|
+
* running byte count crosses the cap, so a header-suppressed oversized body
|
|
216
|
+
* never fully buffers). Responses without a streamable body (test stubs)
|
|
217
|
+
* fall back to text() + a post-read length check, which then bounds only
|
|
218
|
+
* yaml.load, not the buffering step.
|
|
219
|
+
*/
|
|
220
|
+
export const GITHUB_SCENARIO_MAX_BYTES = 1024 * 1024;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* F-2750c4e8: hard ceiling on DISTINCT scenario ids fetched per submission.
|
|
224
|
+
* Matches the submission schema's `scenario_results` maxItems (1000) — the
|
|
225
|
+
* published contract bound — so a schema-valid submission can never hit it,
|
|
226
|
+
* while a hostile pre-schema-gate payload (the fetch loop runs before
|
|
227
|
+
* verify()'s schema rejection lands) cannot drive unbounded sequential
|
|
228
|
+
* authenticated GitHub API calls. Combined with the attempted-id dedupe in
|
|
229
|
+
* loadScenarios, the worst-case network spend per submission is bounded by
|
|
230
|
+
* this constant regardless of payload shape.
|
|
231
|
+
*/
|
|
232
|
+
export const MAX_DISTINCT_SCENARIO_FETCHES = 1000;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* V2-CROSS-BO-001: classified operational fault for the scenario fetch —
|
|
236
|
+
* mirrors the F-dac7e08c adapter discipline in
|
|
237
|
+
* `packages/verify/validators/provenance.js` (transport rejects and non-404
|
|
238
|
+
* provider statuses THROW after the bounded retry, they never masquerade as
|
|
239
|
+
* "the file does not exist"). The `scenario-fetch-fault:` prefix is
|
|
240
|
+
* registered in `@dogfood-lab/verify`'s parseRejectionReason as
|
|
241
|
+
* 'operational', so any surface that stringifies this error routes the
|
|
242
|
+
* incident to ops instead of bouncing a good submission back to the
|
|
243
|
+
* submitter.
|
|
244
|
+
*/
|
|
245
|
+
function scenarioFetchFault(detail) {
|
|
246
|
+
const err = new Error(`scenario-fetch-fault: ${detail}`);
|
|
247
|
+
err.code = 'SCENARIO_FETCH_FAULT';
|
|
248
|
+
return err;
|
|
249
|
+
}
|
|
250
|
+
|
|
218
251
|
/** Default async backoff. Injectable (`opts.sleepImpl`) so tests run instantly. */
|
|
219
252
|
function defaultSleep(ms) {
|
|
220
253
|
return new Promise((r) => setTimeout(r, ms));
|
|
221
254
|
}
|
|
222
255
|
|
|
256
|
+
/**
|
|
257
|
+
* COORD-001: parse a scenario body fetched from a consumer's source repo —
|
|
258
|
+
* the one `yaml.load` call site in this file that sees content a hostile or
|
|
259
|
+
* compromised submitter controls end to end. js-yaml 4.1.1 (the workspace
|
|
260
|
+
* floor; see package.json `"js-yaml": "^4.1.0"`) carries
|
|
261
|
+
* GHSA-h67p-54hq-rp68: chained `<<` merge keys cost O(depth) per level,
|
|
262
|
+
* because `mergeMappings()` (js-yaml lib/loader.js:304-321) re-copies
|
|
263
|
+
* `Object.keys(source)` for the FULL accumulated mapping at every level —
|
|
264
|
+
* so an n-level merge chain costs O(n^2) total. The attack is small BY
|
|
265
|
+
* CONSTRUCTION (each level adds only a couple of YAML lines), so
|
|
266
|
+
* GITHUB_SCENARIO_MAX_BYTES (a 1 MiB cap on the wire size) does not defend
|
|
267
|
+
* it: measured on the reference rig, a chain sized right at that 1 MiB cap
|
|
268
|
+
* costs ~61s of CPU, and scaling is ~quadratic (68 KB -> 127ms, 145 KB ->
|
|
269
|
+
* 656ms). Bumping js-yaml to v5 (which drops merge-key support outright)
|
|
270
|
+
* is blocked — Dependabot #50 is held open because v5 breaks two scripts/
|
|
271
|
+
* gates — so the mitigation lives here instead of in the dependency.
|
|
272
|
+
*
|
|
273
|
+
* CORE_SCHEMA is DEFAULT_SCHEMA minus exactly the YAML-1.1 extras
|
|
274
|
+
* (`timestamp`, `merge`, `binary`, `omap`, `pairs`, `set` — see js-yaml
|
|
275
|
+
* lib/schema/default.js). Without the registered `merge` type, the
|
|
276
|
+
* loader's `keyTag === 'tag:yaml.org,2002:merge'` branch in
|
|
277
|
+
* storeMappingPair() never matches a `<<` key, so `mergeMappings()` is
|
|
278
|
+
* never invoked and the O(depth) copy loop simply cannot run — `<<`
|
|
279
|
+
* resolves as an ordinary (and, against scenario.schema.json, rejected —
|
|
280
|
+
* `additionalProperties: false`) string key instead. This is verified
|
|
281
|
+
* behaviourally, not assumed from the option name, in
|
|
282
|
+
* coord-001-scenario-merge-bomb.test.js: a probe document proves the
|
|
283
|
+
* merge-key never fires, and a second probe proves the real fetch path
|
|
284
|
+
* (not just this helper in isolation) stays protected. Every field
|
|
285
|
+
* scenario.schema.json declares is a plain string/object/array/boolean —
|
|
286
|
+
* none relies on the dropped timestamp/binary/omap/pairs/set types — so
|
|
287
|
+
* this is a pure security hardening with no behavioural cost to a
|
|
288
|
+
* conforming scenario document.
|
|
289
|
+
*
|
|
290
|
+
* Scoped deliberately to THIS call site only. The other two `yaml.load`
|
|
291
|
+
* calls in this file (loadGlobalPolicy, loadRepoPolicy) both read
|
|
292
|
+
* maintainer-committed local files — the attacker does not control their
|
|
293
|
+
* content, only (in loadRepoPolicy's case) which existing file gets
|
|
294
|
+
* selected, and that selection is already segment-validated above.
|
|
295
|
+
* Widening this schema change to those sites is unnecessary risk for zero
|
|
296
|
+
* additional coverage.
|
|
297
|
+
*
|
|
298
|
+
* F-3be85850: this comment previously also named a THIRD "local files"
|
|
299
|
+
* call site, localScenarioFetcher — removed as dead code (zero callers
|
|
300
|
+
* anywhere in the repo, and not reachable even as an external package hook:
|
|
301
|
+
* load-context.js is not in @dogfood-lab/ingest's package.json `exports`
|
|
302
|
+
* map). Its own doc comment claimed it was "Used when dogfood-labs is
|
|
303
|
+
* dogfooding itself," but the real self-dogfood path (self-dogfood.yml)
|
|
304
|
+
* routes through the SAME public ingest.yml repository_dispatch pipeline
|
|
305
|
+
* every consumer uses, via githubScenarioFetcher below — never a local-disk
|
|
306
|
+
* reader. dogfood/scenarios/*.yaml remain the canonical scenario
|
|
307
|
+
* definitions; they are simply fetched over the GitHub Contents API rather
|
|
308
|
+
* than off local disk, even for this repo's own CI.
|
|
309
|
+
*/
|
|
310
|
+
export function parseUntrustedScenarioYaml(text) {
|
|
311
|
+
return yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
|
312
|
+
}
|
|
313
|
+
|
|
223
314
|
/**
|
|
224
315
|
* GitHub scenario fetcher. Loads scenario definitions from a source repo
|
|
225
316
|
* via the GitHub API at a specific commit SHA.
|
|
@@ -231,9 +322,11 @@ function defaultSleep(ms) {
|
|
|
231
322
|
* - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
|
|
232
323
|
* returns `{ scenario, reason }` where `scenario` is the loaded
|
|
233
324
|
* object on success or `null` on failure, and `reason` is one of
|
|
234
|
-
* `'
|
|
235
|
-
* failure (absent on success). The reason gives
|
|
236
|
-
* pivot key when diagnosing a stale scenario-load chain.
|
|
325
|
+
* `'not_found' | 'parse_error' | 'invalid_id' | 'too_large' |
|
|
326
|
+
* 'schema_invalid'` on failure (absent on success). The reason gives
|
|
327
|
+
* operators a pivot key when diagnosing a stale scenario-load chain.
|
|
328
|
+
* A timeout that survives every retry no longer RETURNS a typed
|
|
329
|
+
* reason — it throws `scenario-fetch-fault:` (F-07ab7f86, see below).
|
|
237
330
|
*
|
|
238
331
|
* Both surfaces honour the per-request AbortController timeout
|
|
239
332
|
* (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
|
|
@@ -241,10 +334,23 @@ function defaultSleep(ms) {
|
|
|
241
334
|
* INGEST-PROACT-003: each call makes up to `opts.attempts`
|
|
242
335
|
* (`GITHUB_SCENARIO_FETCH_ATTEMPTS`) tries with exponential backoff, retrying
|
|
243
336
|
* ONLY the transient classes — request timeout, HTTP 5xx, HTTP 429, and network
|
|
244
|
-
* rejects. A 404 (`not_found`), an `invalid_id`,
|
|
245
|
-
*
|
|
246
|
-
* EPERM/EBUSY-only discipline in
|
|
247
|
-
*
|
|
337
|
+
* rejects. A 404 (`not_found`), an `invalid_id`, a `parse_error`, a
|
|
338
|
+
* `too_large`, and a `schema_invalid` are DEFINITIVE answers and are returned
|
|
339
|
+
* immediately without a retry (mirrors the EPERM/EBUSY-only discipline in
|
|
340
|
+
* `lib/rename-with-retry.js`). The backoff sleep is injectable
|
|
341
|
+
* (`opts.sleepImpl`) so tests do not actually wait.
|
|
342
|
+
*
|
|
343
|
+
* V2-CROSS-BO-001 + F-07ab7f86: exhausting the retry budget on a 5xx/429, a
|
|
344
|
+
* transport reject, OR a per-request timeout, or hitting any other non-404
|
|
345
|
+
* non-ok status (401/403 bad credential), THROWS a `scenario-fetch-fault:`
|
|
346
|
+
* classified error instead of returning a typed reason — an outage, a slow-API
|
|
347
|
+
* window, or a token fault is an OPERATIONAL incident, never evidence that
|
|
348
|
+
* the scenario file is absent. Callers (loadScenarios → run.js) propagate the
|
|
349
|
+
* throw so the CLI exits 2 without persisting a rejected record. The
|
|
350
|
+
* exhausted-timeout fault keeps the literal word 'timeout' in its detail so
|
|
351
|
+
* the D1B-004/L1-007 operator pivot key survives (contract updated wave 4;
|
|
352
|
+
* pre-fix the typed 'timeout' reason became a submission-rejecting
|
|
353
|
+
* `scenario-load:` reason that poisoned the run_id via the duplicate guard).
|
|
248
354
|
*
|
|
249
355
|
* @param {string} token - GitHub PAT
|
|
250
356
|
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
|
|
@@ -258,7 +364,13 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
258
364
|
const attempts = opts.attempts ?? GITHUB_SCENARIO_FETCH_ATTEMPTS;
|
|
259
365
|
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
260
366
|
|
|
261
|
-
|
|
367
|
+
// V2-CROSS-BO-003 (F-54e5fde7 family): the submission contract is strictly
|
|
368
|
+
// two-segment. The old 2-way destructure silently dropped a third segment —
|
|
369
|
+
// and worse, the URL below was built from the RAW slug, so `org/repo/extra`
|
|
370
|
+
// reached the authenticated GitHub API verbatim. Fail closed here and build
|
|
371
|
+
// the URL from the VALIDATED segments only.
|
|
372
|
+
const segments = typeof repoSlug === 'string' ? repoSlug.split('/') : [];
|
|
373
|
+
const [org, repo] = segments.length === 2 ? segments : [null, null];
|
|
262
374
|
// commitSha is interpolated into the authenticated (Bearer-token) GitHub API
|
|
263
375
|
// URL's `?ref=` — a shape guard (lowercase-hex, 7–40 chars) refuses anything
|
|
264
376
|
// that could re-target the ref or inject query params, matching how org/repo
|
|
@@ -274,11 +386,14 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
274
386
|
};
|
|
275
387
|
}
|
|
276
388
|
|
|
277
|
-
// One bounded attempt. Returns `{ scenario, reason, retryable }`;
|
|
278
|
-
// below
|
|
389
|
+
// One bounded attempt. Returns `{ scenario, reason, retryable, fault }`;
|
|
390
|
+
// the loop below retries on `retryable` and THROWS `fault` (when present)
|
|
391
|
+
// once the budget is exhausted — so a transient blip is ridden out, but a
|
|
392
|
+
// real outage surfaces as an operational fault, never as `not_found`.
|
|
279
393
|
async function attemptOnce(scenarioId) {
|
|
280
394
|
const path = `dogfood/scenarios/${scenarioId}.yaml`;
|
|
281
|
-
|
|
395
|
+
// V2-CROSS-BO-003: built from the validated org/repo, never the raw slug.
|
|
396
|
+
const url = `https://api.github.com/repos/${org}/${repo}/contents/${path}?ref=${commitSha}`;
|
|
282
397
|
|
|
283
398
|
// D1B-004: AbortController-bounded request. Copied from
|
|
284
399
|
// `packages/verify/validators/provenance.js:80-104`. The 30s default
|
|
@@ -298,33 +413,113 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
298
413
|
signal: controller.signal
|
|
299
414
|
});
|
|
300
415
|
if (!resp.ok) {
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
416
|
+
// V2-CROSS-BO-001: only a 404 means "the scenario file is absent" —
|
|
417
|
+
// that is the sole not_found. A 5xx / 429 is a transient provider
|
|
418
|
+
// fault: retry, then throw operational on exhaustion. Anything else
|
|
419
|
+
// (401/403 bad credential, unexpected 4xx) is a definitive
|
|
420
|
+
// OPERATIONAL fault: throw immediately, never retried, never
|
|
421
|
+
// misread as a missing file.
|
|
422
|
+
if (resp.status === 404) {
|
|
423
|
+
return { scenario: null, reason: 'not_found', retryable: false };
|
|
424
|
+
}
|
|
425
|
+
const fault = scenarioFetchFault(
|
|
426
|
+
`GitHub API HTTP ${resp.status} loading scenario "${scenarioId}" from ${org}/${repo}`
|
|
427
|
+
);
|
|
428
|
+
if (resp.status >= 500 || resp.status === 429) {
|
|
429
|
+
return { scenario: null, retryable: true, fault };
|
|
430
|
+
}
|
|
431
|
+
throw fault;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// V2-CROSS-BO-002: byte cap at the trust boundary. Check the declared
|
|
435
|
+
// Content-Length first (skips even starting an oversized read when the
|
|
436
|
+
// response exposes headers) — the header is attacker-suppliable, so the
|
|
437
|
+
// read below re-enforces the cap on real bytes.
|
|
438
|
+
const declaredLength = typeof resp.headers?.get === 'function'
|
|
439
|
+
? Number(resp.headers.get('content-length'))
|
|
440
|
+
: NaN;
|
|
441
|
+
if (declaredLength > GITHUB_SCENARIO_MAX_BYTES) {
|
|
442
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
443
|
+
}
|
|
444
|
+
// F-35486d38: stream the body with a running byte count and abort the
|
|
445
|
+
// moment the cap is crossed, so a header-suppressed oversized body is
|
|
446
|
+
// never fully buffered. Responses without a streamable body (test
|
|
447
|
+
// stubs, non-spec fetch impls) fall back to text() + post-read check,
|
|
448
|
+
// which bounds yaml.load but not the buffering step.
|
|
449
|
+
if (resp.body && typeof resp.body.getReader === 'function') {
|
|
450
|
+
const reader = resp.body.getReader();
|
|
451
|
+
const chunks = [];
|
|
452
|
+
let received = 0;
|
|
453
|
+
let overflow = false;
|
|
454
|
+
for (;;) {
|
|
455
|
+
const { done, value } = await reader.read();
|
|
456
|
+
if (done) break;
|
|
457
|
+
received += value.byteLength;
|
|
458
|
+
if (received > GITHUB_SCENARIO_MAX_BYTES) {
|
|
459
|
+
overflow = true;
|
|
460
|
+
// cancel() may reject if the connection is already torn down —
|
|
461
|
+
// the too_large verdict below stands either way.
|
|
462
|
+
await reader.cancel().catch(() => {});
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
chunks.push(value);
|
|
466
|
+
}
|
|
467
|
+
if (overflow) {
|
|
468
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
469
|
+
}
|
|
470
|
+
text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
471
|
+
} else {
|
|
472
|
+
text = await resp.text();
|
|
473
|
+
if (Buffer.byteLength(text, 'utf8') > GITHUB_SCENARIO_MAX_BYTES) {
|
|
474
|
+
return { scenario: null, reason: 'too_large', retryable: false };
|
|
475
|
+
}
|
|
305
476
|
}
|
|
306
|
-
text = await resp.text();
|
|
307
477
|
} catch (err) {
|
|
478
|
+
if (err && err.code === 'SCENARIO_FETCH_FAULT') {
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
308
481
|
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
309
482
|
// A timed-out request may succeed on a retry.
|
|
310
483
|
return { scenario: null, reason: 'timeout', retryable: true };
|
|
311
484
|
}
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
485
|
+
// V2-CROSS-BO-001: network reject, DNS failure, etc. — transient, so
|
|
486
|
+
// retry; but on exhaustion this is an outage (operational fault), NOT
|
|
487
|
+
// evidence the scenario file is absent.
|
|
488
|
+
return {
|
|
489
|
+
scenario: null,
|
|
490
|
+
retryable: true,
|
|
491
|
+
fault: scenarioFetchFault(
|
|
492
|
+
`network error loading scenario "${scenarioId}" from ${org}/${repo}: ${err && err.message ? err.message : String(err)}`
|
|
493
|
+
)
|
|
494
|
+
};
|
|
315
495
|
} finally {
|
|
316
496
|
clearTimeout(timer);
|
|
317
497
|
}
|
|
318
498
|
|
|
499
|
+
let scenario;
|
|
319
500
|
try {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return { scenario };
|
|
501
|
+
// COORD-001: merge-key DoS defense — see parseUntrustedScenarioYaml's
|
|
502
|
+
// doc comment. `text` is untrusted (fetched from the submitter's
|
|
503
|
+
// source repo), so it must never reach plain yaml.load().
|
|
504
|
+
scenario = parseUntrustedScenarioYaml(text);
|
|
325
505
|
} catch {
|
|
326
506
|
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
327
507
|
}
|
|
508
|
+
if (!scenario || typeof scenario !== 'object') {
|
|
509
|
+
return { scenario: null, reason: 'parse_error', retryable: false };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// V2-CROSS-BO-002: schema-gate the loaded object before it crosses into
|
|
513
|
+
// verify()'s required-steps enforcement. A parses-but-nonconforming
|
|
514
|
+
// scenario is a SUBMISSION-SIDE fault (the source repo authored it) — it
|
|
515
|
+
// surfaces through loadScenarios as a typed `schema_invalid` reason and
|
|
516
|
+
// becomes a `scenario-load:` rejection at the ingest layer, never a
|
|
517
|
+
// validator fault.
|
|
518
|
+
const validation = validatePayload('scenario', scenario);
|
|
519
|
+
if (!validation.valid) {
|
|
520
|
+
return { scenario: null, reason: 'schema_invalid', retryable: false };
|
|
521
|
+
}
|
|
522
|
+
return { scenario };
|
|
328
523
|
}
|
|
329
524
|
|
|
330
525
|
async function fetchWithReason(scenarioId) {
|
|
@@ -336,7 +531,42 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
336
531
|
for (let i = 0; i < attempts; i++) {
|
|
337
532
|
last = await attemptOnce(scenarioId);
|
|
338
533
|
if (last.scenario || !last.retryable || i === attempts - 1) break;
|
|
339
|
-
|
|
534
|
+
const waitMs = Math.min(RETRY_BASE_MS * (1 << i), RETRY_MAX_MS);
|
|
535
|
+
// F-2a5ddafa: this loop was completely silent on every retry but the
|
|
536
|
+
// last — an operator had zero early-warning signal that the source
|
|
537
|
+
// repo's API was degrading until the retry budget fully exhausted and
|
|
538
|
+
// threw (see the sibling fix in
|
|
539
|
+
// packages/verify/validators/provenance.js's confirm() loops, same
|
|
540
|
+
// finding). 'warn' (not 'error') since the fetch may still succeed on
|
|
541
|
+
// the next attempt.
|
|
542
|
+
logStage('warn', {
|
|
543
|
+
kind: 'scenario_fetch_retry',
|
|
544
|
+
scenario_id: scenarioId,
|
|
545
|
+
attempt: i + 1,
|
|
546
|
+
status_or_reason: last.reason || (last.fault ? last.fault.message : 'unknown'),
|
|
547
|
+
next_backoff_ms: waitMs,
|
|
548
|
+
});
|
|
549
|
+
await sleep(waitMs);
|
|
550
|
+
}
|
|
551
|
+
// V2-CROSS-BO-001: a transient fault that survived every retry is an
|
|
552
|
+
// outage — throw the classified operational error instead of returning a
|
|
553
|
+
// reason the ingest layer would turn into a submission rejection.
|
|
554
|
+
if (last.fault) {
|
|
555
|
+
throw last.fault;
|
|
556
|
+
}
|
|
557
|
+
// F-07ab7f86: timeout EXHAUSTION is outage-shaped too — a sustained
|
|
558
|
+
// slow-API window (every attempt >timeoutMs) is the same operational
|
|
559
|
+
// class as exhausted ECONNREFUSED/5xx, not evidence the submission is
|
|
560
|
+
// bad. Throw the classified fault, keeping the literal word 'timeout'
|
|
561
|
+
// in the detail so the D1B-004/L1-007 operator pivot key survives in
|
|
562
|
+
// the error message. (This deliberately supersedes the earlier pinned
|
|
563
|
+
// contract where exhausted-timeout returned the typed reason and became
|
|
564
|
+
// a `scenario-load:` rejection — that permanently poisoned run_ids
|
|
565
|
+
// during slow-API windows via the _rejected duplicate guard.)
|
|
566
|
+
if (last.reason === 'timeout') {
|
|
567
|
+
throw scenarioFetchFault(
|
|
568
|
+
`timeout after ${attempts} attempt(s) loading scenario "${scenarioId}" from ${org}/${repo}`
|
|
569
|
+
);
|
|
340
570
|
}
|
|
341
571
|
// Strip the internal `retryable` flag from the public contract.
|
|
342
572
|
const { retryable: _drop, ...result } = last;
|
|
@@ -357,32 +587,91 @@ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
|
357
587
|
/**
|
|
358
588
|
* Load all scenario definitions referenced by a submission's scenario_results.
|
|
359
589
|
*
|
|
360
|
-
*
|
|
361
|
-
* the
|
|
362
|
-
* `
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
590
|
+
* F-efe4f893: this function now runs BEFORE the schema gate on untrusted
|
|
591
|
+
* consumer input (the F-3bfc2885 production wiring), so it must be defensive:
|
|
592
|
+
* - a non-array `scenario_results` returns empty-handed (the schema
|
|
593
|
+
* rejection downstream in verify() is the authoritative signal);
|
|
594
|
+
* - an entry that is not a non-null object carrying a string `scenario_id`
|
|
595
|
+
* is NEVER fetched — pre-fix, `/^[\w-]+$/.test(undefined)` coerced to the
|
|
596
|
+
* string 'undefined' and issued a real authenticated GitHub API request
|
|
597
|
+
* per malformed entry. It pushes a typed `malformed_entry` error instead.
|
|
598
|
+
*
|
|
599
|
+
* F-2750c4e8: every ATTEMPTED id is cached (successes AND typed failures), so
|
|
600
|
+
* a repeated id — loaded or failed — costs exactly one fetch, and the number
|
|
601
|
+
* of distinct fetches is capped at {@link MAX_DISTINCT_SCENARIO_FETCHES}
|
|
602
|
+
* (the schema's own maxItems bound). Entries beyond the cap get a typed
|
|
603
|
+
* `fetch_cap` error without touching the network. Thrown
|
|
604
|
+
* `scenario-fetch-fault:`s are deliberately NOT cached — they propagate
|
|
605
|
+
* immediately (see below), so outage semantics survive the dedupe.
|
|
606
|
+
*
|
|
607
|
+
* DESIGN RULING (wave 4) — three-way failure split:
|
|
608
|
+
* - true 404 (`not_found`, typed surface only) → NOT a rejection. The fleet
|
|
609
|
+
* is mixed (some consumers commit dogfood/scenarios/, some don't);
|
|
610
|
+
* nothing declared means nothing to enforce. Surfaces on `warnings` so
|
|
611
|
+
* run.js lands it on verification.warnings (the v1.6.0
|
|
612
|
+
* accepted-with-warning channel) and required-steps enforcement is
|
|
613
|
+
* simply skipped for that scenario.
|
|
614
|
+
* - malformed committed file (`parse_error` / `schema_invalid` /
|
|
615
|
+
* `too_large` / `invalid_id`) → `errors` → submission-bad
|
|
616
|
+
* `scenario-load:` rejection (unchanged).
|
|
617
|
+
* - outage (`scenario-fetch-fault:` throw, incl. exhausted timeout) →
|
|
618
|
+
* PROPAGATES deliberately; the CLI's outer catch emits a structured
|
|
619
|
+
* operational error and exits 2 without persisting (V2-CROSS-BO-001).
|
|
620
|
+
*
|
|
621
|
+
* The legacy `fetch(id)` truthiness surface cannot discriminate not_found
|
|
622
|
+
* from a malformed file, so its `null` keeps the historical
|
|
623
|
+
* rejection-on-error behavior.
|
|
367
624
|
*
|
|
368
625
|
* @param {object} submission
|
|
369
626
|
* @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
|
|
370
|
-
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
|
|
627
|
+
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[], warnings: string[] }>}
|
|
371
628
|
*/
|
|
372
629
|
export async function loadScenarios(submission, scenarioFetcher) {
|
|
373
630
|
const scenarios = new Map();
|
|
374
631
|
const errors = [];
|
|
632
|
+
const warnings = [];
|
|
633
|
+
|
|
634
|
+
const results = submission && typeof submission === 'object'
|
|
635
|
+
? submission.scenario_results
|
|
636
|
+
: undefined;
|
|
637
|
+
if (!Array.isArray(results)) {
|
|
638
|
+
// Non-array shapes (string, object, null) iterate wrong or crash —
|
|
639
|
+
// notably a STRING iterates characters. Skip entirely; verify()'s schema
|
|
640
|
+
// gate rejects the submission with the authoritative reason.
|
|
641
|
+
return { scenarios, errors, warnings };
|
|
642
|
+
}
|
|
375
643
|
|
|
376
644
|
const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
|
|
645
|
+
// F-2750c4e8: id → true once an id has been resolved (success OR typed
|
|
646
|
+
// failure), so repeats never re-fetch. Faults are uncached: they throw.
|
|
647
|
+
const attempted = new Set();
|
|
648
|
+
let distinctFetches = 0;
|
|
377
649
|
|
|
378
|
-
for (const sr of
|
|
650
|
+
for (const sr of results) {
|
|
651
|
+
if (!sr || typeof sr !== 'object' || Array.isArray(sr) || typeof sr.scenario_id !== 'string') {
|
|
652
|
+
errors.push(
|
|
653
|
+
'scenario_results entry is not an object with a string scenario_id (reason: malformed_entry)'
|
|
654
|
+
);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
379
657
|
const id = sr.scenario_id;
|
|
380
|
-
if (
|
|
658
|
+
if (attempted.has(id)) continue;
|
|
659
|
+
attempted.add(id);
|
|
660
|
+
|
|
661
|
+
if (distinctFetches >= MAX_DISTINCT_SCENARIO_FETCHES) {
|
|
662
|
+
errors.push(
|
|
663
|
+
`scenario "${id}" not fetched — distinct-scenario fetch cap (${MAX_DISTINCT_SCENARIO_FETCHES}) reached (reason: fetch_cap)`
|
|
664
|
+
);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
distinctFetches++;
|
|
381
668
|
|
|
382
669
|
if (supportsTypedReason) {
|
|
383
670
|
const result = await scenarioFetcher.fetchWithReason(id);
|
|
384
671
|
if (result && result.scenario) {
|
|
385
672
|
scenarios.set(id, result.scenario);
|
|
673
|
+
} else if (result && result.reason === 'not_found') {
|
|
674
|
+
warnings.push(`scenario definition not found for "${id}" — required_steps unenforced`);
|
|
386
675
|
} else {
|
|
387
676
|
const reason = result && result.reason ? result.reason : 'unknown';
|
|
388
677
|
errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
|
|
@@ -397,5 +686,5 @@ export async function loadScenarios(submission, scenarioFetcher) {
|
|
|
397
686
|
}
|
|
398
687
|
}
|
|
399
688
|
|
|
400
|
-
return { scenarios, errors };
|
|
689
|
+
return { scenarios, errors, warnings };
|
|
401
690
|
}
|