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
6 changes: 6 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 17 additions & 4 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3800,7 +3800,7 @@ async function isAuthorizedReviewThreadAuthor(
association: string | null | undefined,
): Promise<boolean> {
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);
}
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions test/unit/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down