diff --git a/src/env.d.ts b/src/env.d.ts index fb965729e5..181ef007f5 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -191,6 +191,12 @@ declare global { /** Comma-separated GitHub logins assigned to filed upstream-drift issues (default: the gittensory * maintainer). Lets a self-host operator route drift issues to their own team. */ GITTENSORY_DRIFT_ISSUE_ASSIGNEES?: string; + /** Comma-separated GitHub bot logins ADDITIONALLY trusted to author review-thread blockers via scanner + * comments (src/github/backfill.ts isTrustedScannerReviewThreadAuthor), merged with the built-in baseline + * (superagent[bot], superagent-security[bot], superagent-security-dev[bot], brin[bot]). Lets a self-host + * operator running a different third-party scanner (CodeQL, Snyk, Semgrep, SonarCloud, DeepSource, etc.) + * get the same review-thread trust as the built-in scanners (#4614). */ + TRUSTED_SCANNER_BOT_LOGINS?: string; /** Self-host default Discord webhook URL — per-action notifications (merged/closed/manual) for any repo * not in the built-in per-repo map. Lets a self-host operator wire one channel without a source edit. */ DISCORD_WEBHOOK_URL?: string; diff --git a/src/github/backfill.ts b/src/github/backfill.ts index b6f7f08f94..fb05163367 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -3800,7 +3800,7 @@ async function isAuthorizedReviewThreadAuthor( association: string | null | undefined, ): Promise { if (isOwnReviewThreadAuthor(login)) return false; - if (isTrustedScannerReviewThreadAuthor(login)) return true; + if (isTrustedScannerReviewThreadAuthor(env, login)) return true; if (isMaintainerReviewThreadAuthor(association)) return true; return isVerifiedMemberReviewThreadAuthor(env, repoFullName, token, memberPermissionCache, admissionKey, login, association); } @@ -3843,10 +3843,23 @@ function isVerifiedMemberReviewThreadAuthor( return verified; } -// External scanner GitHub App bot logins allowed to create review-thread blockers. +// External scanner GitHub App bot logins allowed to create review-thread blockers -- built-in baseline. A +// self-hoster running a different scanner (CodeQL, Snyk, Semgrep, SonarCloud, DeepSource, etc.) can trust it +// too via TRUSTED_SCANNER_BOT_LOGINS (comma-separated logins), ADDITIVE to this baseline -- same shape as +// resolveDriftAssignees in src/upstream/ruleset.ts -- instead of that scanner's comments silently falling +// through every trust check and never blocking (#4614). const TRUSTED_SCANNER_REVIEW_THREAD_AUTHORS = new Set(["superagent[bot]", "superagent-security[bot]", "superagent-security-dev[bot]", "brin[bot]"]); -function isTrustedScannerReviewThreadAuthor(login: string | null | undefined): boolean { - return typeof login === "string" && TRUSTED_SCANNER_REVIEW_THREAD_AUTHORS.has(login.toLowerCase()); +function isTrustedScannerReviewThreadAuthor(env: Env, login: string | null | undefined): boolean { + if (typeof login !== "string") return false; + const normalized = login.toLowerCase(); + if (TRUSTED_SCANNER_REVIEW_THREAD_AUTHORS.has(normalized)) return true; + const raw = env.TRUSTED_SCANNER_BOT_LOGINS; + if (typeof raw !== "string" || !raw.trim()) return false; + return raw + .split(",") + .map((extra) => extra.trim().toLowerCase()) + .filter((extra) => extra.length > 0) + .includes(normalized); } // Match only OUR OWN app bot login (a `gittensory` / `gittensory-orb[bot]` PREFIX), never a third-party slug diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 054fe59b76..7ca54bbef8 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -5590,6 +5590,97 @@ describe("GitHub backfill", () => { expect(blockers.map((blocker) => blocker.path)).toEqual(["src/superagent.ts", "src/superagent-security.ts", "src/superagent-security-dev.ts", "src/brin.ts"]); }); + it("trusts self-host-configured TRUSTED_SCANNER_BOT_LOGINS additively alongside the built-in defaults (#4614)", async () => { + // Whitespace + case variation + an empty entry between commas -- exercises the trim/lowercase/filter + // handling, not just a bare exact match. + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", TRUSTED_SCANNER_BOT_LOGINS: " CodeQL[bot] ,,Snyk-Security[bot]" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString() !== "https://api.github.com/graphql") return new Response("not found", { status: 404 }); + return Response.json({ + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + { + isResolved: false, + isOutdated: false, + path: "src/codeql-finding.ts", + line: 5, + comments: { nodes: [{ body: "**P1:** Configured CodeQL blocker", author: { login: "codeql[bot]" }, authorAssociation: "NONE" }] }, + }, + { + isResolved: false, + isOutdated: false, + path: "src/snyk-finding.ts", + line: 15, + comments: { nodes: [{ body: "**P1:** Configured Snyk blocker", author: { login: "snyk-security[bot]" }, authorAssociation: "NONE" }] }, + }, + { + isResolved: false, + isOutdated: false, + path: "src/superagent-still-trusted.ts", + line: 25, + comments: { nodes: [{ body: "**P1:** Built-in default still trusted", author: { login: "superagent-security[bot]" }, authorAssociation: "NONE" }] }, + }, + { + isResolved: false, + isOutdated: false, + path: "src/unconfigured-scanner.ts", + line: 35, + comments: { nodes: [{ body: "**P1:** Unconfigured scanner stays untrusted", author: { login: "semgrep[bot]" }, authorAssociation: "NONE" }] }, + }, + ], + }, + }, + }, + }, + }); + }); + + const blockers = await fetchLiveReviewThreadBlockers(env, "JSONbored/gittensory", 1900, "public-token"); + + expect(blockers.map((blocker) => blocker.title)).toEqual(["Configured CodeQL blocker", "Configured Snyk blocker", "Built-in default still trusted"]); + expect(blockers.map((blocker) => blocker.authorLogin)).toEqual(["codeql[bot]", "snyk-security[bot]", "superagent-security[bot]"]); + }); + + it("ignores a whitespace-only TRUSTED_SCANNER_BOT_LOGINS override and keeps only the built-in defaults trusted", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token", TRUSTED_SCANNER_BOT_LOGINS: " " }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString() !== "https://api.github.com/graphql") return new Response("not found", { status: 404 }); + return Response.json({ + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: [ + { + isResolved: false, + isOutdated: false, + path: "src/codeql-finding.ts", + line: 5, + comments: { nodes: [{ body: "**P1:** Not configured, must not block", author: { login: "codeql[bot]" }, authorAssociation: "NONE" }] }, + }, + { + isResolved: false, + isOutdated: false, + path: "src/superagent-still-trusted.ts", + line: 25, + comments: { nodes: [{ body: "**P1:** Built-in default still trusted", author: { login: "superagent-security[bot]" }, authorAssociation: "NONE" }] }, + }, + ], + }, + }, + }, + }, + }); + }); + + const blockers = await fetchLiveReviewThreadBlockers(env, "JSONbored/gittensory", 1901, "public-token"); + + expect(blockers.map((blocker) => blocker.authorLogin)).toEqual(["superagent-security[bot]"]); + }); + it("paginates review threads so blockers beyond the first page cannot hide", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); const queries: string[] = [];