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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ APP_ID=
PRIVATE_KEY=
WEBHOOK_SECRET=
MARKETPLACE_WEBHOOK_SECRET=
# Used by PR security scans. Contributor trust scoring runs locally.
BRIN_API_BASE=https://api.brin.sh
PORT=3000
LOG_LEVEL=info
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
data/
.env
*.log
.DS_Store
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Brin GitHub App
# Superagent GitHub App

A GitHub App that automatically scans pull requests for security threats and evaluates contributor trust profiles using the [Brin](https://brin.sh) API.
A GitHub App that automatically scans pull requests for security threats and evaluates contributor trust profiles.

## What it does

When installed on a repository, the app reacts to pull request events and runs two parallel checks:

**PR Security Scan** -- Analyzes the PR diff for security threats (credential leaks, obfuscated payloads, dependency attacks, etc.) and reports a score from 0-100 with a verdict.

**Contributor Trust Check** -- Evaluates the PR author's GitHub profile across identity, behavior, content, and social graph dimensions to flag accounts that warrant additional review.
**Contributor Trust Check** -- Evaluates the PR author's GitHub profile across identity, behavior, and content dimensions to flag accounts that warrant additional review.

Results are surfaced as:

Expand Down Expand Up @@ -39,7 +39,7 @@ Create a new GitHub App at `https://github.com/settings/apps/new` with these set
- Checks: Read & Write
- Pull requests: Read & Write
- Issues: Read & Write (for PR comments)
- Contents: Read (for `.github/brin.yml` config)
- Contents: Read (for repository config)
- Metadata: Read

**Webhook events:**
Expand All @@ -61,6 +61,8 @@ PRIVATE_KEY=<contents of your .pem file, with literal \n for newlines>
WEBHOOK_SECRET=<the secret you set when creating the app>
```

Contributor trust scoring runs locally in this app and uses the GitHub App installation token to fetch profile and activity signals.

### 3. Install dependencies and run

```bash
Expand All @@ -78,7 +80,7 @@ Go to your app's installation page and install it on the repositories you want t

## Repo configuration

Repositories can optionally add a `.github/brin.yml` file to customize behavior:
Repositories can optionally add a configuration file to customize behavior:

```yaml
prScan:
Expand Down Expand Up @@ -112,21 +114,23 @@ src/
├── services/
│ ├── prScan.ts # PR scan orchestration
│ ├── contributorTrust.ts # Contributor trust orchestration
│ ├── contributorScanner.ts # Local contributor scoring facade
│ ├── githubContributor.ts # GitHub profile/activity signal collection
│ ├── checkRuns.ts # GitHub Check Runs API wrapper
│ ├── comments.ts # Marker-based comment management + rendering
│ ├── labels.ts # Label ensure/set logic
│ └── config.ts # .github/brin.yml loader
│ └── config.ts # Repository config loader
└── lib/
├── env.ts # Environment variable validation
├── logger.ts # Structured logging (pino)
├── types.ts # Shared types, constants, label/marker defs
├── brinApi.ts # Brin API HTTP client
├── contributorScoring.ts # Contributor scoring formulas
└── policy.ts # Verdict evaluation and threshold logic
```

## Re-running checks

Maintainers can re-run any Brin check from the GitHub UI by clicking "Re-run" on the check run. The app handles `check_run.rerequested` events and re-executes the corresponding scan.
Maintainers can re-run any Superagent check from the GitHub UI by clicking "Re-run" on the check run. The app handles `check_run.rerequested` events and re-executes the corresponding scan.

## Development

Expand Down
30 changes: 1 addition & 29 deletions src/lib/__tests__/brinApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { scanContributor, scanPr } from "../brinApi.js";
import { scanPr } from "../brinApi.js";

vi.mock("../logger.js", () => ({
childLogger: () => ({
Expand Down Expand Up @@ -37,31 +37,3 @@ describe("scanPr", () => {
);
});
});

describe("scanContributor", () => {
beforeEach(() => {
process.env.BRIN_API_BASE = "https://brin.example";
vi.unstubAllGlobals();
});

it("forwards a GitHub token for private contributor access", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ score: 90, verdict: "safe" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);

await scanContributor("octocat", {
githubToken: "ghs_installation_token",
});

expect(fetchMock).toHaveBeenCalledWith(
"https://brin.example/contributor/octocat?details=true&mode=full",
expect.objectContaining({
headers: { "x-github-token": "ghs_installation_token" },
}),
);
});
});
216 changes: 216 additions & 0 deletions src/lib/__tests__/contributorScoring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
compositeScore,
emptyActivitySummary,
scoreContributor,
scoreContributorBehavior,
scoreContributorContent,
scoreContributorIdentity,
verdictForScore,
type ActivitySummary,
type PrSummary,
type UserProfile,
} from "../contributorScoring.js";

const NOW = new Date("2026-01-15T12:00:00.000Z");
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function profile(ageDays?: number, email?: string): UserProfile {
return {
login: "testuser",
accountAgeDays: ageDays,
publicRepos: 10,
followers: 50,
email,
reposContributedTo: [],
orgs: [],
hasGpgKeys: false,
};
}

function activity(
totalEvents: number,
lastEventDaysAgo: number | undefined,
oldestEventDaysAgo: number | undefined,
distinctRepos7d: number,
hasForkOnly: boolean,
): ActivitySummary {
return {
...emptyActivitySummary(),
repos: ["owner/repo"],
totalEvents,
lastEventAt:
lastEventDaysAgo == null
? undefined
: new Date(NOW.getTime() - lastEventDaysAgo * MS_PER_DAY),
oldestEventAt:
oldestEventDaysAgo == null
? undefined
: new Date(NOW.getTime() - oldestEventDaysAgo * MS_PER_DAY),
pushCount: totalEvents,
prCount: hasForkOnly ? 0 : 1,
distinctRepos7d,
hasForkOnlyActivity: hasForkOnly,
};
}

describe("contributor identity scoring", () => {
it("scores established contributors high", () => {
const p = profile(2000, "user@example.com");
const [score, threats] = scoreContributorIdentity(p, true, ["rust-lang", "tokio-rs"]);
expect(score).toBe(100);
expect(threats).toEqual([]);
});

it("flags new accounts with no trust signals", () => {
const [score, threats] = scoreContributorIdentity(profile(5), false, []);
expect(score).toBe(8);
expect(threats).toHaveLength(1);
expect(threats[0]?.type).toBe("malicious_new_account");
});

it("caps org bonus and distinguishes corporate email", () => {
const manyOrgs = Array.from({ length: 5 }, (_, i) => `org-${i}`);
const [orgScore] = scoreContributorIdentity(profile(400, "dev@gmail.com"), false, manyOrgs);
const [freeScore] = scoreContributorIdentity(profile(1000, "dev@gmail.com"));
const [corpScore] = scoreContributorIdentity(profile(1000, "dev@acme.co"));

expect(orgScore).toBe(93);
expect(freeScore).toBe(73);
expect(corpScore).toBe(78);
});

it("counts profile metadata and contribution volume bonuses", () => {
const p = profile(4000, "dev@company.io");
p.company = "Company Inc";
p.followers = 5000;
p.totalContributions = 20000;
p.blog = "https://dev.company.io";
p.xUsername = "devhandle";
p.bio = "Staff engineer";

const [score, threats] = scoreContributorIdentity(p, true, ["rust-lang"]);
expect(score).toBe(100);
expect(threats).toEqual([]);
});
});

describe("contributor behavior scoring", () => {
it("scores normal active contributors high", () => {
const [score, threats] = scoreContributorBehavior(
activity(30, 1, 60, 3, false),
1000,
NOW,
);
expect(score).toBe(90);
expect(threats).toEqual([]);
});

it("detects dormant accounts with a narrow activity spike", () => {
const [score, threats] = scoreContributorBehavior(
activity(25, 0, 5, 4, false),
365,
NOW,
);
expect(score).toBe(65);
expect(threats[0]?.type).toBe("sleeper_account");
});

it("penalizes cross-repo velocity and fork-only activity", () => {
const [velocityScore] = scoreContributorBehavior(activity(50, 1, 30, 12, false), 500, NOW);
const [forkOnlyScore] = scoreContributorBehavior(activity(10, 2, 20, 2, true), 30, NOW);

expect(velocityScore).toBe(70);
expect(forkOnlyScore).toBe(80);
});

it("detects PR spray, unsolicited PRs, rejected PRs, and low merge rate", () => {
const spray = activity(10, 0, 1, 5, false);
spray.prOpened24h = 7;
spray.prOpenedCount = 7;
spray.prTargetRepos7d = 5;
expect(scoreContributorBehavior(spray, 90, NOW)[1].some((t) => t.type === "pr_spray"))
.toBe(true);

const unsolicited = activity(10, 0, 7, 3, false);
unsolicited.prOpenedCount = 5;
unsolicited.unsolicitedPrRatio = 0.9;
unsolicited.unsolicitedPrRepoCount = 4;
unsolicited.prTargetRepos = ["a/1", "b/2", "c/3", "d/4", "e/5"];
expect(scoreContributorBehavior(unsolicited, 200, NOW)[0]).toBe(65);

const rejected = activity(10, 0, 7, 3, false);
rejected.prRejectedRepos = 4;
rejected.prClosedNotMerged = 4;
expect(scoreContributorBehavior(rejected, 200, NOW)[1].some((t) => t.type === "pr_rejected_across_repos"))
.toBe(true);

const lowMerge = activity(10, 0, 7, 3, false);
lowMerge.prClosedNotMerged = 6;
lowMerge.unsolicitedPrRatio = 0.5;
expect(scoreContributorBehavior(lowMerge, 200, NOW)[1].some((t) => t.type === "low_merge_rate"))
.toBe(true);
});
});

describe("contributor content scoring", () => {
it("keeps content at 100 when there are no recent PRs", () => {
expect(scoreContributorContent(emptyActivitySummary(), 0)).toEqual([100, []]);
});

it("penalizes empty bodies and missing issue linkage", () => {
const prs: PrSummary[] = [0, 5, 10, 2, 3].map((bodyLen, index) => ({
title: `fix: typo ${index}`,
bodyLen,
hasIssueRef: false,
repo: `a/${index}`,
}));
const summary = { ...emptyActivitySummary(), prOpenedCount: 5, recentPrs: prs };

const [score, threats] = scoreContributorContent(summary, 0);
expect(score).toBe(65);
expect(threats.some((t) => t.type === "no_issue_linkage")).toBe(true);
});

it("penalizes low effort PRs to unfamiliar repos", () => {
const summary = {
...emptyActivitySummary(),
prOpenedCount: 4,
recentPrs: [1, 2, 3, 80].map((bodyLen, index) => ({
title: `fix: ${index}`,
bodyLen,
hasIssueRef: index === 3,
repo: `a/${index}`,
})),
};

const [score, threats] = scoreContributorContent(summary, 0.75);
expect(score).toBe(55);
expect(threats.some((t) => t.type === "low_effort_pr")).toBe(true);
});
});

describe("contributor composite scoring", () => {
it("uses only identity, behavior, and content weights", () => {
expect(compositeScore({ identity: 90, behavior: 80, content: 70 })).toBe(81);
});

it("uses conservative verdict boundaries", () => {
expect(verdictForScore(80)).toBe("safe");
expect(verdictForScore(79)).toBe("caution");
expect(verdictForScore(49)).toBe("suspicious");
expect(verdictForScore(19)).toBe("dangerous");
});

it("caps high confidence because contributor graph is not used", () => {
const p = profile(4000, "dev@company.io");
p.followers = 5000;
p.totalContributions = 20000;
p.orgs = ["rust-lang", "tokio-rs"];
p.hasGpgKeys = true;
const result = scoreContributor(p, activity(30, 1, 60, 3, false), NOW);

expect(result.score).toBeGreaterThanOrEqual(85);
expect(result.confidence).toBe("medium");
});
});
30 changes: 1 addition & 29 deletions src/lib/brinApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env } from "./env.js";
import { childLogger } from "./logger.js";
import type { PrScanResult, ContributorResult } from "./types.js";
import type { PrScanResult } from "./types.js";

export async function scanPr(
owner: string,
Expand Down Expand Up @@ -32,31 +32,3 @@ export async function scanPr(
return {};
}
}

export async function scanContributor(
login: string,
options: { githubToken?: string } = {},
): Promise<ContributorResult> {
const url = `${env.brinApiBase}/contributor/${login}?details=true&mode=full`;
const log = childLogger({ service: "brin-api", endpoint: "contributor", login });
const headers = options.githubToken
? { "x-github-token": options.githubToken }
: undefined;

try {
const res = await fetch(url, {
headers,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
log.warn({ status: res.status }, "Brin contributor API returned non-OK status");
return {};
}
const data = (await res.json()) as ContributorResult;
log.info({ score: data.score, verdict: data.verdict }, "Contributor scan response");
return data;
} catch (err) {
log.error({ err }, "Brin contributor API request failed");
return {};
}
}
Loading
Loading