Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/loopover-engine/src/signals/change-guardrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,26 @@ export function guardrailPathMatches(changedPaths: string[], hardGuardrailGlobs:
* longer populated), we cannot prove the PR avoids a guarded path, so treat it as a hit. No guardrails
* configured ⇒ never a hit. Pure.
*/
/**
* #9017 (GHSA-rjhf-3xrf-j72w): the REST file-list walk that produces `changedPaths` stops silently at its own
* 10-page x 100-file cap and returns the TRUNCATED list with no truncation flag. `isGuardrailHit` used to fail
* safe only on an EMPTY list, so a truncated NON-empty list that happened to omit the guarded file read as
* "no guardrail hit" — meaning a PR padded past 1000 files could hide a `.github/workflows/**` or config edit
* from the hard guardrail and auto-merge it, i.e. arbitrary CI execution, the highest-value target here.
*
* A list at or above the cap is therefore treated as UNVERIFIABLE (same posture as the empty list): we cannot
* prove the PR avoids a guarded path, so a human decides. This deliberately doubles as the hard changed-file
* ceiling the advisory also asks for — a legitimate contributor PR to these repos is never 1000 files, so the
* two remedies collapse into one rule with no extra plumbing through the fetch layer (and therefore no way for
* a future caller to obtain paths from some other source and forget to pass a truncation flag along).
*/
export const GUARDRAIL_UNVERIFIABLE_FILE_COUNT = 1000;

export function isGuardrailHit(changedPaths: string[], hardGuardrailGlobs: string[]): boolean {
if (hardGuardrailGlobs.length === 0) return false;
return changedPaths.length === 0 || changedPathsHittingGuardrail(changedPaths, hardGuardrailGlobs).length > 0;
return (
changedPaths.length === 0 ||
changedPaths.length >= GUARDRAIL_UNVERIFIABLE_FILE_COUNT ||
changedPathsHittingGuardrail(changedPaths, hardGuardrailGlobs).length > 0
);
}
86 changes: 47 additions & 39 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3437,9 +3437,10 @@ export function createApp() {
});

app.get("/v1/repos/:owner/:repo/pulls/:number/maintainer-packet", async (c) => {
const unauthorized = await requireStaticProtectedApiToken(c);
if (unauthorized) return unauthorized;
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
// #9045: repo-scoped — this route was the one sibling that never checked the MCP read allowlist.
const unauthorized = await requireStaticProtectedApiToken(c, fullName);
if (unauthorized) return unauthorized;
const number = Number(c.req.param("number"));
if (!Number.isInteger(number) || number <= 0) return c.json({ error: "invalid_pull_number" }, 400);
const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([
Expand All @@ -3461,11 +3462,9 @@ export function createApp() {
});

app.get("/v1/repos/:owner/:repo/pulls/:number/reviewability", async (c) => {
const unauthorized = await requireStaticProtectedApiToken(c);
if (unauthorized) return unauthorized;
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
if (identity?.kind === "static" && identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403);
const unauthorized = await requireStaticProtectedApiToken(c, fullName);
if (unauthorized) return unauthorized;
const number = Number(c.req.param("number"));
if (!Number.isInteger(number) || number <= 0) return c.json({ error: "invalid_pull_number" }, 400);
const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([
Expand Down Expand Up @@ -3531,15 +3530,10 @@ export function createApp() {
// override_audit history or the shadow's queued recommendation, just a flag that a shadow is soaking.
// Gated behind the same most-conservative repo-scoped read precedent the reviewability route (#6154) uses.
app.get("/v1/repos/:owner/:repo/gate-config/effective", async (c) => {
const unauthorized = await requireStaticProtectedApiToken(c);
if (unauthorized) return unauthorized;
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- requireStaticProtectedApiToken above already rejected null and session identities, so only static tokens reach here. */
if (!identity || identity.kind !== "static") return c.json({ error: "unauthorized" }, 401);
// Only the shared, end-user-obtainable static `mcp` token is allowlist-scoped; operator-only api/internal
// tokens stay trusted — same repo-scoped read precedent the reviewability route (#6154) uses.
if (identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403);
// #9045: the MCP read-allowlist check now lives in requireStaticProtectedApiToken itself.
const unauthorized = await requireStaticProtectedApiToken(c, fullName);
if (unauthorized) return unauthorized;
const storageEnv = c.env as unknown as StorageEnv;
const [override, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]);
return c.json({
Expand All @@ -3559,16 +3553,10 @@ export function createApp() {
// applied_at / clear_at). Live row wins; soaking shadow fills in only when live is absent. 404 when neither
// is active — same not-found convention as issue-quality. Auth matches gate-config/effective above.
app.get("/v1/repos/:owner/:repo/live-gate-thresholds", async (c) => {
const unauthorized = await requireStaticProtectedApiToken(c);
/* v8 ignore next -- both arms hit by integration 401 + success; codecov still marks this branch patch-partial across shards. */
if (unauthorized) return unauthorized;
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- requireStaticProtectedApiToken above already rejected null and session identities, so only static tokens reach here. */
if (!identity || identity.kind !== "static") return c.json({ error: "unauthorized" }, 401);
// Only the shared, end-user-obtainable static `mcp` token is allowlist-scoped; operator-only api/internal
// tokens stay trusted — same repo-scoped read precedent the reviewability route (#6154) uses.
if (identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403);
// #9045: the MCP read-allowlist check now lives in requireStaticProtectedApiToken itself.
const unauthorized = await requireStaticProtectedApiToken(c, fullName);
if (unauthorized) return unauthorized;
const storageEnv = c.env as unknown as StorageEnv;
const [live, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]);
const fields = toLiveGateThresholdFields(authoritativeGateOverride(live, shadow));
Expand Down Expand Up @@ -4412,7 +4400,7 @@ export function createApp() {
// ⇒ the collector REQUIRES it, so an operator can lock the write path down after distributing the matching
// ORB_COLLECTOR_TOKEN to exporters. Bounded by a hard body ceiling, and dedup'd via UNIQUE(instance_id, repo_hash, pr_hash).
app.post("/v1/orb/ingest", async (c) => {
if (!(await isAuthorizedOrbIngest(c.env, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
if (!(await isAuthorizedIngest(c.env.ORB_INGEST_TOKEN, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length"));
if (body === null) return c.json({ error: "payload_too_large" }, 413);
if (!body) return c.json({ error: "invalid_request" }, 400);
Expand All @@ -4425,7 +4413,7 @@ export function createApp() {
// route above (same optional bearer-token gate, same hard body ceiling — readOrbIngestBody is generic over
// request bytes despite the name, so it's reused as-is rather than duplicated).
app.post("/v1/ams/ingest", async (c) => {
if (!(await isAuthorizedAmsIngest(c.env, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
if (!(await isAuthorizedIngest(c.env.AMS_INGEST_TOKEN, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401);
const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length"));
if (body === null) return c.json({ error: "payload_too_large" }, 413);
if (!body) return c.json({ error: "invalid_request" }, 400);
Expand Down Expand Up @@ -6452,11 +6440,26 @@ function installationRecordInScope(
return scopedInstallationIds.has(record.installationId) || scopedAccountLogins.has(record.accountLogin.toLowerCase());
}

async function requireStaticProtectedApiToken(c: ProtectedRouteContext): Promise<Response | null> {
/** #9045: `repoFullName` folds the MCP read-allowlist check INTO this gate rather than leaving it to each
* route to remember. Three of the four repo-scoped callers hand-rolled the identical check; the fourth
* (maintainer-packet) simply omitted it, so the shared, end-user-obtainable `mcp` token could read the full
* packet — every issue, PR, file, review, and check summary — for ANY repo over HTTP, while the MCP tool this
* route mirrors denied exactly that. Worse, MCP_READ_REPO_ALLOWLIST is fail-closed by default (unset ⇒ deny
* all), so the intended posture was "deny everything" while this route allowed everything. Passing the repo
* makes the check structural: a future repo-scoped route cannot forget it without also failing to pass the
* argument it needs anyway. Operator-only `api`/`internal` tokens stay trusted and are never allowlist-scoped. */
async function requireStaticProtectedApiToken(c: ProtectedRouteContext, repoFullName?: string): Promise<Response | null> {
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before static-token-only route guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
if (identity.kind === "session") return c.json({ error: "static_token_required" }, 403);
if (
repoFullName !== undefined &&
identity.actor === "mcp" &&
!(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, repoFullName)
) {
return c.json({ error: "forbidden_repo" }, 403);
}
return null;
}

Expand Down Expand Up @@ -6606,19 +6609,24 @@ function toIsoQueryDate(value: string): string | undefined {
// OPEN (matching today's live fleet — deploying this is non-breaking). Once the operator sets the token, the
// collector REQUIRES an exact bearer match, so the write path can be locked down after the matching
// ORB_COLLECTOR_TOKEN is rolled out to exporters.
async function isAuthorizedOrbIngest(env: Env, token: string | undefined): Promise<boolean> {
if (!env.ORB_INGEST_TOKEN) return true;
// Constant-time compare (mirrors every other secret check in auth/security) — a `===` here is timing-attack
// vulnerable for a shared secret.
return timingSafeEqual(token, env.ORB_INGEST_TOKEN);
}

// Optional AMS-ingest auth (#5681), same fail-open shape as isAuthorizedOrbIngest: unset ⇒ open ingress, set ⇒
// the collector requires an exact bearer match. A separate token/env var from ORB_INGEST_TOKEN so the two
// products' collector credentials can be rotated or locked down independently.
async function isAuthorizedAmsIngest(env: Env, token: string | undefined): Promise<boolean> {
if (!env.AMS_INGEST_TOKEN) return true;
return timingSafeEqual(token, env.AMS_INGEST_TOKEN);
/**
* #9046: the SINGLE authorization rule for every telemetry-collector ingest endpoint (Orb and AMS), so the two
* products cannot drift apart again. They previously had separate, near-identical copies — and AMS ended up
* BOTH fail-open AND missing from the strict rate class, purely because it was a second copy nobody kept in
* lockstep. Any future collector route gets the correct posture by calling this rather than hand-rolling it;
* the per-product secret stays separate (passed in) so the two credentials rotate independently.
*
* FAILS CLOSED when the token is unset. Both copies previously returned `true` for an unset token — the
* shipped default — so anyone with network access could POST batches, and Orb's batches feed the PUBLISHED
* accuracy numbers. Network isolation was doing all the work (the tunnel exposes only the shot path, 8787
* binds to loopback), which the planned hosted Orb removes. An unconfigured collector now rejects rather than
* accepting anonymous writes; operators who want ingest set the secret, matching every other credentialed
* surface here. Constant-time compare (mirrors auth/security) — a `===` is timing-attack vulnerable for a
* shared secret.
*/
async function isAuthorizedIngest(configuredToken: string | undefined, presentedToken: string | undefined): Promise<boolean> {
if (!configuredToken) return false;
return timingSafeEqual(presentedToken, configuredToken);
}

function requiresApiToken(path: string): boolean {
Expand Down
3 changes: 3 additions & 0 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export function routeClassForPath(path: string): RateLimitClass {
// Orb telemetry ingest: unauthenticated + write, accepting anonymized batches from untrusted
// self-host instances. Strict (10/min per IP) caps abuse — legitimate instances export hourly.
if (path === "/v1/orb/ingest") return "strict";
// #9046: the AMS collector is the same shape as the Orb one above (unauthenticated-by-default
// write surface, hard body ceiling) but was landing in the looser `normal` class purely by omission.
if (path === "/v1/ams/ingest") return "strict";
if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal";
// GitHub's OAuth Device Authorization Grant (RFC 8628) is polling-by-design: a client polls
// /device/poll at a server-specified interval (this repo's own default is 5s) for up to the device
Expand Down
13 changes: 12 additions & 1 deletion src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
shouldPublishReviewCheck,
} from "../review/check-names";
import { buildReviewThreadBlocker, unreadableReviewThreadBlocker, type ReviewThreadBlocker } from "../review/review-thread-findings";
import { GUARDRAIL_UNVERIFIABLE_FILE_COUNT } from "../signals/change-guardrail";
import { delayUntil, HISTORICAL_BACKFILL_RESERVED_HEADROOM, LOW_REST_RATE_LIMIT_REMAINING, shouldWaitForGitHubRateLimit } from "./rate-limit";
import {
githubRateLimitAdmissionKeyForPublicToken,
Expand Down Expand Up @@ -2428,7 +2429,17 @@ async function fetchPullRequestFiles(
): Promise<GitHubFilePayload[]> {
incr(PULL_REQUEST_FILES_FETCH_METRIC, { caller });
const files = await githubPaginatedList<GitHubFilePayload>(env, repoFullName, `/pulls/${pullNumber}/files`, token, admissionKey);
if (files) return files;
if (files) {
// #9017 (GHSA-rjhf-3xrf-j72w): githubPaginatedList stops silently at its page cap and returns a TRUNCATED
// list with no flag, so a padded PR could hide a guarded file from the hard guardrail. isGuardrailHit now
// fails safe on a list at/above GUARDRAIL_UNVERIFIABLE_FILE_COUNT (the decision that actually protects the
// merge); this warning makes the truncation visible to an operator rather than silent, matching what the
// GraphQL supplement paths in this file already do at their own caps.
if (files.length >= GUARDRAIL_UNVERIFIABLE_FILE_COUNT) {
warnings.push(`File list for #${pullNumber} hit the pagination cap (${files.length} files); the changed-file set may be truncated and the PR is held for human review.`);
}
return files;
}
const fallback = token ? await fetchPullRequestDetailsFromGraphQl(env, repoFullName, pullNumber, token, admissionKey).catch(() => undefined) : undefined;
if (fallback) return fallback.files;
warnings.push(`File sync failed for #${pullNumber}: GitHub REST and GraphQL detail fetches failed.`);
Expand Down
1 change: 1 addition & 0 deletions src/signals/change-guardrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export {
type GuardrailPathMatch,
guardrailPathMatches,
isGuardrailHit,
GUARDRAIL_UNVERIFIABLE_FILE_COUNT,
} from "../../packages/loopover-engine/src/signals/change-guardrail";
33 changes: 33 additions & 0 deletions test/unit/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4019,6 +4019,39 @@ describe("GitHub backfill", () => {
expect((await listCheckSummaries(env, "JSONbored/gittensory", 20)).map((check) => check.name).sort()).toEqual(["lint", "test"]);
});

// #9017 (GHSA-rjhf-3xrf-j72w): githubPaginatedList stops silently at its page cap and returns the TRUNCATED
// list with no flag. isGuardrailHit now fails safe on a list at/above the cap (that is what protects the
// merge); this asserts the fetch layer also SURFACES the truncation to the operator instead of staying
// silent, matching what the GraphQL supplement paths in this file already do at their own caps.
it("#9017: warns when the PR-files walk hits the pagination cap, so a truncated file set is never silent", async () => {
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
await seedRegisteredRepo(env);
await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", {
number: 22,
title: "Padded PR",
state: "open",
user: { login: "oktofeesh1" },
head: { sha: "padsha" },
labels: [],
body: "",
});
// Every page is full and advertises another — the walk ends by exhausting the cap, not by running out.
const fullPage = Array.from({ length: 100 }, (_, i) => ({ filename: `docs/pad-${i}.md`, status: "modified", additions: 1, deletions: 0, changes: 1 }));
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/pulls/22/files")) {
return Response.json(fullPage, { headers: { link: '<https://api.github.com/repositories/1/files?page=99>; rel="next"' } });
}
return Response.json([]);
});

const result = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "full", cursor: 0 });

// The segment reports "partial" (reconciliation independently notices the truncated set) — the point of
// this test is the NAMED warning, which is what tells an operator *why*.
expect(result.warnings.some((w) => w.includes("pagination cap") && w.includes("#22"))).toBe(true);
});

it("keeps the pages already fetched when a later PR-files page fails", async () => {
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
await seedRegisteredRepo(env);
Expand Down
Loading
Loading