diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md new file mode 100644 index 000000000..658327570 --- /dev/null +++ b/.github/PR_MODERATION.md @@ -0,0 +1,122 @@ +# PR submission moderation + +The PR Burst Guard protects review capacity using creation metadata. It runs on +new and reopened PRs targeting any branch, after this workflow merges. It does not +judge whether a patch is useful. Existing open PRs are not bulk-closed on rollout. + +## Policy + +| Control | Default | Enforcement | +| --- | --- | --- | +| Burst allowance | Five creations per author in 20 minutes | Close and lock excess PRs | +| Severe burst | More than ten creations in 20 minutes | Attempt account block; alert maintainers | +| Known denied account | User ID `108231307`, currently `dajiaohuang` | Close and lock its incoming/reopened PR | +| Trusted contributors | Live repository write, maintain, or admin permission | Exempt from the burst guard | +| Notifications | `@affaan-m @haelyra` | One bot-authored issue per author per UTC day, plus one escalation comment | + +Edit `pr-burst-policy.json` to change thresholds, notification recipients or +denied numeric IDs. This policy limits submission rate, with no limit on the +total number of open PRs a contributor can accumulate over time. + +Closed and draft PRs count toward burst detection. Counting uses stable author +IDs and paginated REST creation metadata, without the search index. A run checks +historical windows from the triggering PR's creation time through newer API +rows. Queue delays, out-of-order delivery and replaced pending runs cannot hide +a burst already present in that snapshot. Excess PRs are determined in creation +order across all scanned windows, with PR number breaking timestamp ties. +Already closed excess PRs count +but are not mutated again. + +Reopening an old ordinary PR does not add a new creation to the burst count. +Reopening a denied author's PR triggers denial even outside the time window. +Actions moderation happens after creation and cannot prevent every CI job from starting. Account +blocks cannot stop someone from using a different GitHub account. + +## Activate + +1. Merge the reviewed workflow and script into `main`. +2. To enable automatic account blocking, `affaan-m` creates a dedicated + fine-grained token with the account permission **Block another user: write**. + Store it as repository Actions secret `PR_MODERATION_BLOCK_TOKEN`. Use GitHub's + secret UI or `gh secret set PR_MODERATION_BLOCK_TOKEN --repo affaan-m/ECC` + with its hidden prompt. Do not paste credentials into chat or source files. +3. Verify a genuine subsequent guard run and its job summary. Without the secret, + closure and alerting still run. A severe burst records blocking as unavailable + in the alert and fails the job so maintainers see the missing protection. + +ECC currently belongs to the personal account `affaan-m`. GitHub account blocking +applies across **all repositories owned by that account**. The script checks the +token's authenticated identity before calling the personal blocking API. A +collaborator's personal block cannot protect this repository. The normal +`GITHUB_TOKEN` cannot perform account blocking. Organization blocking requires +a separate implementation if repository ownership changes. + +Closing PRs cannot reset burst counts. Rapid closing and resubmission still +count as creations within the same rolling window. + +## Verify without moderating anyone + +```sh +node tests/ci/pr-burst-guard.test.js +node tests/ci/pr-burst-guard-workflow.test.js +node scripts/ci/validate-workflow-security.js +``` + +The workflow test executes the actual CLI against a disposable HTTP fixture. +It verifies close, lock, owner-identity blocking, secret separation, deduplicated +notifications, missing-secret failure and collaborator exemption. + +For a live read-only replay, supply `GITHUB_TOKEN` through your secure shell +environment, then run: + +```sh +GITHUB_REPOSITORY=affaan-m/ECC node scripts/ci/pr-burst-guard.js --dry-run --pr 3580 +``` + +Expected for the October 14 snapshot: author `jwcastillo`, count `14`, `block: +true`, and nine excess PRs. Dry run never uses the blocking secret or writes to +GitHub. Hosted event delivery and real account blocking require post-merge +verification; the local fixture cannot certify those platform operations. + +## Security and recovery + +- The privileged event checks out its trusted default-branch SHA and two moderation files. + It never checks out PR code, installs dependencies, or executes PR text. +- Credentials enter through environment variables. Owner identity verification + and account blocking receive the owner credential. Repository moderation uses + `GITHUB_TOKEN`. +- API failures cannot silently become an allowlist exemption. Only collaborator + lookup `404` means an outsider; other errors fail the run. +- Close or lock failures appear in the alert and fail the job. Block failures + preserve closure and alerting, then fail the job. +- Repeated runs update the bot-authored alert instead of creating new issues. + A contributor-created lookalike cannot suppress alerts. Escalation remains + recorded if a later run has a lower count. +- To roll back, disable **PR Burst Guard** in Actions. Remove its secret if + abandoning automatic blocking. +- To restore a contributor, the owner removes the account block. Remove any + denied user ID from the policy, then unlock and reopen selected PRs manually. + +## October 15 investigation + +Authenticated GitHub GraphQL returned zero `CLOSED_EVENT` or `REOPENED_EVENT` +entries across all 55 `dajiaohuang` PRs that were open at the investigation's start. Their latest update was +October 8. The public timeline therefore supplies no evidence that they reopened +on October 15. Restored visibility after GitHub spam classification is a possible +explanation, but GitHub's private moderation history was unavailable. A block on +a collaborator's personal account would also have a different scope from an +owner block. The owner's actual block state was not available to this session. + +The visible newer burst was 14 PRs from `jwcastillo`, #3567 through #3580, between +October 14 at 19:01:37 and 19:07:14 UTC. Volume alone does not establish the quality +of those patches. The maintainer subsequently authorized closing all 55 open +`dajiaohuang` PRs. All closures were verified, and a fresh paginated REST scan +found zero remaining open PRs from numeric user ID `108231307`. + +This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet, +release publication and shared application installation remain separate work. + +## GitHub references + +- [Personal account blocking REST endpoints](https://docs.github.com/en/rest/users/blocking) +- [Privileged pull request event security](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target) diff --git a/.github/pr-burst-policy.json b/.github/pr-burst-policy.json new file mode 100644 index 000000000..ce7d8927a --- /dev/null +++ b/.github/pr-burst-policy.json @@ -0,0 +1,7 @@ +{ + "windowMinutes": 20, + "closeAbove": 5, + "blockAbove": 10, + "deniedUserIDs": [108231307], + "notify": ["affaan-m", "haelyra"] +} diff --git a/.github/workflows/pr-burst-guard.yml b/.github/workflows/pr-burst-guard.yml new file mode 100644 index 000000000..550655339 --- /dev/null +++ b/.github/workflows/pr-burst-guard.yml @@ -0,0 +1,39 @@ +name: PR Burst Guard + +on: + pull_request_target: + types: [opened, reopened] + +# Process one author's ledger serially. A newer pending event replaces an older +# pending event; each run reads all PR states instead of relying on event counts. +concurrency: + group: pr-burst-${{ github.repository }}-${{ github.event.pull_request.user.id }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + moderate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # pull_request_target's SHA is the trusted default branch, never a PR head. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: | + .github/pr-burst-policy.json + scripts/ci/pr-burst-guard.js + sparse-checkout-cone-mode: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20.x' + - name: Enforce PR submission policy + env: + GITHUB_TOKEN: ${{ github.token }} + PR_MODERATION_BLOCK_TOKEN: ${{ secrets.PR_MODERATION_BLOCK_TOKEN }} + run: node scripts/ci/pr-burst-guard.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5576d4699..bd8e3d96b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -433,6 +433,17 @@ Translations live under `docs/` (e.g. `docs/zh-CN`, `docs/zh-TW`, `docs/ja-JP`). ## Pull Request Process +### Submission volume + +Please submit at most five PRs within any 20-minute period. Automated moderation +closes and locks excess PRs, including drafts. More than ten submissions within +20 minutes triggers a maintainer alert and an account-block attempt. Closed PRs +still count toward the window. Contributors with repository write access bypass +this policy. Maintainers can review exceptions through the moderation alert. + +See the [moderation runbook](.github/PR_MODERATION.md) +for activation, credential requirements, and recovery. + ### 1. PR Title Format ``` diff --git a/scripts/ci/pr-burst-guard.js b/scripts/ci/pr-burst-guard.js new file mode 100644 index 000000000..2329561c7 --- /dev/null +++ b/scripts/ci/pr-burst-guard.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** Metadata-only moderation. Never execute PR code or use PR titles/bodies. */ +const fs = require('node:fs'); +const path = require('node:path'); + +const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})(?:\[bot\])?$/i; + +function validatePolicy(policy) { + for (const key of ['windowMinutes', 'closeAbove', 'blockAbove']) { + if (!Number.isSafeInteger(policy[key]) || policy[key] < 1) throw new Error(`Invalid policy ${key}`); + } + if (policy.blockAbove <= policy.closeAbove) throw new Error('blockAbove must exceed closeAbove'); + if (!Array.isArray(policy.deniedUserIDs) || !policy.deniedUserIDs.every(Number.isSafeInteger)) { + throw new Error('Invalid deniedUserIDs'); + } + if (!Array.isArray(policy.notify) || !policy.notify.every(login => LOGIN.test(login))) { + throw new Error('Invalid notification logins'); + } +} + +function githubAPI(token, fetcher = fetch) { + if (!token) throw new Error('GitHub token is required'); + return async (method, route, body) => { + if (!/^\/[a-zA-Z0-9][a-zA-Z0-9/_.?=&%[\]-]*$/.test(route)) throw new Error('Invalid GitHub API path'); + const response = await fetcher(`https://api.github.com${route}`, { + method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + // Never print response bodies, which can contain attacker-controlled text. + const error = new Error(`GitHub ${method} failed: HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return response.status === 204 ? null : response.json(); + }; +} + +async function listPages(api, route, stop = () => false) { + const rows = []; + for (let page = 1; page <= 100; page++) { + const batch = await api('GET', `${route}&per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error('Expected GitHub list response'); + rows.push(...batch); + if (batch.length < 100 || stop(batch)) return rows; + } + throw new Error('Pagination limit reached; refusing an incomplete moderation decision'); +} + +function evaluate(rows, current, policy, end) { + validatePolicy(policy); + if (!Number.isFinite(end)) throw new Error('Invalid window timestamp'); + const since = end - policy.windowMinutes * 60000; + const recent = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id && Date.parse(pr.created_at) >= since && Date.parse(pr.created_at) <= end) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const denied = policy.deniedUserIDs.includes(current.user.id); + const excess = denied ? [current] : recent.slice(policy.closeAbove); + return { count: recent.length, denied, block: denied || recent.length > policy.blockAbove, + excess: excess.map(pr => ({ number: pr.number, state: pr.state })), + since: new Date(since).toISOString(), until: new Date(end).toISOString() }; +} + +function strongestWindow(rows, current, policy, eventTime) { + const ordered = [...new Map([...rows, current].map(pr => [pr.number, pr])).values()] + .filter(pr => pr.user.id === current.user.id) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.number - b.number); + const times = ordered.map(pr => Date.parse(pr.created_at)); + const excess = new Map(evaluate(rows, current, policy, eventTime).excess.map(pr => [pr.number, pr])); + let left = 0; + let count = 0; + let end = eventTime; + for (let right = 0; right < times.length; right++) { + while (times[left] < times[right] - policy.windowMinutes * 60000) left++; + if (times[right] >= eventTime && right - left + 1 >= count) { + count = right - left + 1; + end = times[right]; + } + if (times[right] >= eventTime && right - left + 1 > policy.closeAbove) { + excess.set(ordered[right].number, { number: ordered[right].number, state: ordered[right].state }); + } + } + const peak = evaluate(rows, current, policy, end); + return { ...peak, excess: peak.denied ? peak.excess : [...excess.values()] }; +} + +async function blockAuthor(blockAPI, owner, login) { + if (!blockAPI) return 'Unavailable: PR_MODERATION_BLOCK_TOKEN is not configured'; + try { + if (owner.type !== 'User') throw new Error('Only personal repository owners are supported'); + const identity = await blockAPI('GET', '/user'); + if (identity.login.toLowerCase() !== owner.login.toLowerCase()) { + throw new Error('Block token must use the repository owner identity'); + } + await blockAPI('PUT', `/user/blocks/${login}`); + return 'Blocked by repository owner (all repositories owned by this account)'; + } catch (error) { + return `Failed: ${error.message}`; + } +} + +async function notify(api, root, current, result, policy) { + // A bot-authored ledger prevents contributors from spoofing a suppression marker. + const marker = ``; + const issues = await listPages(api, `${root}/issues?state=all&sort=created&direction=desc`, + batch => batch.some(issue => issue.created_at && issue.created_at.slice(0, 10) < result.until.slice(0, 10))); + const existing = issues.find(issue => !issue.pull_request && issue.user.login === 'github-actions[bot]' + && issue.body?.includes(marker)); + const escalation = ''; + if (existing?.body.includes(escalation) && !result.block) return; + const mentions = policy.notify.map(login => `@${login}`).join(' '); + const body = [marker, result.block ? escalation : '', `${mentions}: PR submission moderation triggered.`, + '', `Author: @${current.user.login} (user ID ${current.user.id}).`, + `Observed: ${result.count} PR creations between ${result.since} and ${result.until}.`, + `Policy: close PRs beyond ${policy.closeAbove}; block beyond ${policy.blockAbove} in ${policy.windowMinutes} minutes.`, + `Denied account: ${result.denied}.`, `Blocking: ${result.blockStatus}.`, + `Excess PRs across scanned windows: ${result.excess.map(pr => `#${pr.number}`).join(', ') || 'none'}.`, + `Enforcement errors: ${result.errors.join('; ') || 'none'}.`, + '', 'Counts include closed and draft PRs. Volume triggers moderation, not a judgment about individual patch quality.', + 'Review the Actions run and PR timeline before lifting an account block.'].filter(Boolean).join('\n'); + if (!existing) { + await api('POST', `${root}/issues`, { title: `PR burst alert: ${current.user.login}`, body }); + } else { + // Notify again only when a soft-limit alert escalates to blocking. + if (result.block && !existing.body.includes(escalation)) { + await api('POST', `${root}/issues/${existing.number}/comments`, { + body: `${mentions}: the PR burst escalated. Blocking: ${result.blockStatus}.`, + }); + } + await api('PATCH', `${root}/issues/${existing.number}`, { body }); + } +} + +async function moderate({ api, blockAPI, repository, number, action = 'opened', policy, dryRun = false, now = Date.now() }) { + validatePolicy(policy); + if (!/^[a-z\d-]+\/[a-z\d_.-]+$/i.test(repository) || !Number.isSafeInteger(number) || number < 1) { + throw new Error('Invalid repository or PR number'); + } + if (!['opened', 'reopened'].includes(action)) throw new Error('Unsupported PR event'); + const root = `/repos/${repository}`; + const repo = await api('GET', root); + const current = await api('GET', `${root}/pulls/${number}`); + if (!LOGIN.test(current.user.login)) throw new Error('Invalid PR author login'); + let permission; + try { + permission = (await api('GET', `${root}/collaborators/${current.user.login}/permission`)).permission; + } catch (error) { + if (error.status !== 404) throw error; + permission = 'none'; + } + if (['admin', 'maintain', 'write'].includes(permission)) return { exempt: true, author: current.user.login }; + // Anchor opened events to GitHub's creation time so Actions queue delays cannot erase a burst. + const end = action === 'opened' ? Date.parse(current.created_at) : now; + const since = end - policy.windowMinutes * 60000; + const rows = await listPages(api, `${root}/pulls?state=all&sort=created&direction=desc`, + batch => batch.some(pr => Date.parse(pr.created_at) < since)); + // Inspect later windows too: delivery order and replaced pending runs must not + // let an older event conceal a newer burst already present in the API snapshot. + const result = { ...strongestWindow(rows, current, policy, end), author: current.user.login, + dryRun, errors: [], blockStatus: 'Below blocking threshold' }; + if (dryRun) { + if (result.block) result.blockStatus = 'Dry run: would attempt owner-scoped blocking'; + return result; + } + if (result.excess.length === 0) return result; + if (result.block) result.blockStatus = await blockAuthor(blockAPI, repo.owner, current.user.login); + for (const pr of result.excess) { + if (pr.state !== 'open') continue; + try { + await api('PATCH', `${root}/pulls/${pr.number}`, { state: 'closed' }); + await api('PUT', `${root}/issues/${pr.number}/lock`, { lock_reason: 'spam' }); + } catch (error) { + result.errors.push(`#${pr.number}: ${error.message}`); + } + } + await notify(api, root, current, result, policy); + return result; +} + +async function main() { + const dryRun = process.argv.includes('--dry-run'); + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) : {}; + const prIndex = process.argv.indexOf('--pr'); + const number = prIndex >= 0 ? Number(process.argv[prIndex + 1]) : event.pull_request?.number; + const repository = process.env.GITHUB_REPOSITORY; + const policy = JSON.parse(fs.readFileSync(path.join(__dirname, '../../.github/pr-burst-policy.json'), 'utf8')); + const result = await moderate({ api: githubAPI(process.env.GITHUB_TOKEN), + blockAPI: !dryRun && process.env.PR_MODERATION_BLOCK_TOKEN ? githubAPI(process.env.PR_MODERATION_BLOCK_TOKEN) : undefined, + repository, number, action: event.action || 'opened', policy, dryRun }); + const report = JSON.stringify(result, null, 2); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## PR burst guard\n\n\`\`\`json\n${report}\n\`\`\`\n`); + if (result.errors?.length || /^(Failed|Unavailable):/.test(result.blockStatus || '')) process.exitCode = 1; +} + +if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); +module.exports = { evaluate, moderate, githubAPI, listPages, validatePolicy, strongestWindow }; diff --git a/tests/ci/pr-burst-guard-workflow.test.js b/tests/ci/pr-burst-guard-workflow.test.js new file mode 100644 index 000000000..52592b08e --- /dev/null +++ b/tests/ci/pr-burst-guard-workflow.test.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const exec = promisify(execFile); +const root = path.resolve(__dirname, '../..'); + +test('actual CLI closes six excess PRs, locks them, blocks with owner identity, and deduplicates alerts over HTTP', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr-guard-')); + const mutations = []; + let issue; + let permission = 'read'; + const rows = Array.from({ length: 11 }, (_, i) => ({ number: i + 1, + user: { id: 42, login: 'contributor' }, state: 'open', draft: i === 4, + created_at: new Date(Date.UTC(2026, 9, 14, 19, 0, i)).toISOString(), + body: '::error::untrusted PR body', title: '$(untrusted title)' })); + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let text = ''; + for await (const chunk of req) text += chunk; + const body = text ? JSON.parse(text) : undefined; + const send = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; + if (req.method !== 'GET') mutations.push({ method: req.method, route: url.pathname, body, token: req.headers.authorization }); + if (url.pathname === '/repos/owner/repo') return send({ owner: { login: 'owner', type: 'User' } }); + if (url.pathname === '/user') { + assert.equal(req.headers.authorization, 'Bearer owner-token'); + return send({ login: 'owner' }); + } + if (req.method === 'GET' && url.pathname.endsWith('/pulls/11')) return send(rows[10]); + if (url.pathname.includes('/collaborators/')) return send({ permission }); + if (req.method === 'GET' && url.pathname.endsWith('/pulls')) return send(rows.slice().reverse()); + if (url.pathname.endsWith('/issues')) { + if (req.method === 'GET') return send(issue ? [issue] : []); + issue = { number: 88, user: { login: 'github-actions[bot]' }, ...body }; + return send(issue); + } + if (url.pathname.endsWith('/issues/88') && req.method === 'PATCH') { issue = { ...issue, ...body }; return send(issue); } + if (req.method === 'PATCH' && /\/pulls\/\d+$/.test(url.pathname)) { + const pr = rows.find(row => row.number === Number(url.pathname.split('/').pop())); + pr.state = body.state; return send(pr); + } + if (req.method === 'PUT') { res.statusCode = 204; return res.end(); } + res.statusCode = 404; send({ message: 'fixture route missing' }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + // Test-only transport redirection. Production offers no API host override. + const preload = path.join(tmp, 'transport.cjs'); + fs.writeFileSync(preload, `const original = global.fetch; global.fetch = (url, options) => original('http://127.0.0.1:${port}' + new URL(url).pathname + new URL(url).search, options);`); + const event = path.join(tmp, 'event.json'); + fs.writeFileSync(event, JSON.stringify({ action: 'opened', sender: { login: 'owner' }, pull_request: { number: 11 } })); + const summary = path.join(tmp, 'summary.md'); + const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo', + GITHUB_TOKEN: 'moderation-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token', + GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary }; + const run = () => exec(process.execPath, ['scripts/ci/pr-burst-guard.js'], { cwd: root, env }); + const first = JSON.parse((await run()).stdout); + assert.equal(first.count, 11); + assert.match(first.blockStatus, /^Blocked/); + await run(); + assert.equal(mutations.filter(m => m.route.endsWith('/issues') && m.method === 'POST').length, 1); + assert.equal(mutations.filter(m => /\/pulls\/\d+$/.test(m.route)).length, 6); + assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 6); + assert(mutations.some(m => m.route === '/user/blocks/contributor' && m.token === 'Bearer owner-token')); + assert(mutations.filter(m => m.route.startsWith('/repos/')).every(m => m.token === 'Bearer moderation-token')); + assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|moderation-token/); + assert(issue.body.includes('@affaan-m @haelyra')); + + delete env.PR_MODERATION_BLOCK_TOKEN; + await assert.rejects(run(), error => { + assert.equal(error.code, 1); + assert.match(JSON.parse(error.stdout).blockStatus, /not configured/); + return true; + }); + permission = 'write'; + assert.equal(JSON.parse((await run()).stdout).exempt, true); + } finally { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/ci/pr-burst-guard.test.js b/tests/ci/pr-burst-guard.test.js new file mode 100644 index 000000000..4a0bd3c83 --- /dev/null +++ b/tests/ci/pr-burst-guard.test.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { evaluate, moderate, githubAPI, listPages, strongestWindow } = require('../../scripts/ci/pr-burst-guard'); + +const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, deniedUserIDs: [999], notify: ['maintainer'] }; +const start = Date.parse('2026-10-14T19:00:00Z'); +const pull = (number, extra = {}) => ({ number, user: { id: 42, login: 'contributor' }, + created_at: new Date(start + number * 1000).toISOString(), state: 'open', ...extra }); +const pulls = n => Array.from({ length: n }, (_, i) => pull(i + 1)); + +function fixture(n, options = {}) { + const rows = options.rows || pulls(n); + const calls = []; + let issue; + const api = async (method, route, body) => { + calls.push({ method, route, body }); + if (route === '/repos/owner/repo') return { owner: { login: 'owner', type: 'User' } }; + if (route.endsWith(`/pulls/${n}`)) return rows.find(p => p.number === n); + if (route.includes('/collaborators/')) return { permission: options.permission || 'read' }; + if (route.includes('/pulls?')) return rows.slice().reverse(); + if (route.includes('/issues?')) return issue ? [issue] : []; + if (route.endsWith('/issues') && method === 'POST') { + issue = { number: 101, user: { login: 'github-actions[bot]' }, ...body }; + return issue; + } + if (route.endsWith('/issues/101') && method === 'PATCH') { issue = { ...issue, ...body }; return issue; } + if (route.endsWith('/issues/101/comments')) return {}; + if (method === 'PATCH') return {}; + if (method === 'PUT') return {}; + throw new Error(`Unexpected ${method} ${route}`); + }; + const args = { api, repository: 'owner/repo', number: n, action: 'opened', policy, ...options }; + return { args, calls, api }; +} + +test('five allowed, sixth closed, ten not blocked, eleventh escalates', () => { + for (const n of [5, 6, 10, 11]) { + const result = evaluate(pulls(n), pull(n), policy, start + n * 1000); + assert.equal(result.count, n); + assert.equal(result.block, n > 10); + assert.deepEqual(result.excess.map(p => p.number), Array.from({ length: Math.max(0, n - 5) }, (_, i) => i + 6)); + } +}); + +test('counts closed and draft PRs by stable user ID, excludes other authors and out-of-window rows', () => { + const rows = [...pulls(5), pull(6, { state: 'closed', draft: true }), pull(7, { user: { id: 99 } }), + pull(8, { created_at: new Date(start - 21 * 60000).toISOString() })]; + assert.equal(evaluate(rows, pull(6), policy, start + 6000).count, 6); +}); + +test('delayed events retain their creation window and deterministic number tie-break', () => { + const rows = pulls(11).map(p => ({ ...p, created_at: new Date(start).toISOString() })); + assert.equal(evaluate(rows.reverse(), rows[0], policy, start).block, true); + assert.deepEqual(evaluate(rows, rows[0], policy, start).excess.map(p => p.number), [6, 7, 8, 9, 10, 11]); +}); + +test('moderation closes and locks excess, then deduplicates maintainer alert', async () => { + const f = fixture(6); + await moderate(f.args); + await moderate(f.args); + assert.equal(f.calls.filter(c => c.method === 'POST' && c.route.endsWith('/issues')).length, 1); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/6') && c.body.state === 'closed')); + assert(f.calls.some(c => c.method === 'PUT' && c.route.endsWith('/issues/6/lock'))); +}); + +test('no owner credential reports blocking unavailable and still notifies', async () => { + const f = fixture(11); + const result = await moderate(f.args); + assert.match(result.blockStatus, /not configured/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('not configured'))); +}); + +test('owner token identity checked before personal block, escalation notifies exactly once', async () => { + const ownerCalls = []; + const blockAPI = async (method, route) => { + ownerCalls.push({ method, route }); + if (route === '/user') return { login: 'owner' }; + return {}; + }; + const f = fixture(6, { blockAPI }); + await moderate(f.args); + f.args.number = 11; + // Use a second snapshot while retaining the alert ledger. + const original = f.args.api; + f.args.api = (method, route, body) => route.endsWith('/pulls/11') ? pull(11) + : route.includes('/pulls?') ? pulls(11).reverse() : original(method, route, body); + await moderate(f.args); + await moderate(f.args); + assert(ownerCalls.some(c => c.method === 'PUT' && c.route === '/user/blocks/contributor')); + assert.equal(f.calls.filter(c => c.route.endsWith('/comments')).length, 1); +}); + +test('wrong personal identity never blocks and records the failure', async () => { + const f = fixture(11, { blockAPI: async (method, route) => { + assert.equal(route, '/user'); return { login: 'someone-else' }; + } }); + assert.match((await moderate(f.args)).blockStatus, /owner identity/); +}); + +test('write/admin collaborators bypass; actor identity cannot exempt an outsider', async () => { + for (const permission of ['write', 'maintain', 'admin']) { + const f = fixture(11, { permission }); + assert.equal((await moderate(f.args)).exempt, true); + assert.equal(f.calls.filter(c => ['POST', 'PATCH', 'PUT'].includes(c.method)).length, 0); + } + const f = fixture(6, { actor: 'owner' }); + assert.equal((await moderate(f.args)).count, 6); +}); + +test('denylisted user is closed on reopen even outside the creation window', async () => { + const f = fixture(1, { action: 'reopened', rows: [pull(1, { user: { id: 999, login: 'denied' } })] }); + assert.equal((await moderate(f.args)).denied, true); + assert(f.calls.some(c => c.method === 'PATCH' && c.route.endsWith('/pulls/1'))); +}); + +test('dry run performs no mutations or owner-token calls', async () => { + const f = fixture(11, { dryRun: true, blockAPI: async () => { throw new Error('secret used'); } }); + assert.equal((await moderate(f.args)).block, true); + assert(f.calls.every(c => c.method === 'GET')); +}); + +test('REST transport rejects off-host paths and never retries mutations', async () => { + const api = githubAPI('test-token', async () => { throw new Error('must not fetch'); }); + await assert.rejects(api('GET', '//attacker.test'), /path/); + let attempts = 0; + const limited = githubAPI('token', async () => { + attempts++; return { ok: false, status: 403 }; + }); + await assert.rejects(limited('PUT', '/user/blocks/example'), /403/); + assert.equal(attempts, 1); +}); + +test('out-of-order older event sees newer burst and retains historical peak', async () => { + const rows = [...pulls(11), pull(12, { created_at: new Date(start + 30 * 60000).toISOString() })]; + assert.equal(strongestWindow(rows, pull(6), policy, start + 6000).count, 11); + const f = fixture(6, { rows }); + assert.equal((await moderate(f.args)).block, true); +}); + +test('separate bursts close excess from each window even if pending events disappear', () => { + const rows = [...pulls(6), ...pulls(6).map(p => ({ ...p, number: p.number + 6, + created_at: new Date(start + 60 * 60000 + p.number * 1000).toISOString() }))]; + const result = strongestWindow(rows, pull(6), policy, start + 6000); + assert.equal(result.count, 6); + assert.deepEqual(result.excess.map(p => p.number), [6, 12]); +}); + +test('pagination includes page two and refuses silent truncation', async () => { + let calls = 0; + const rows = await listPages(async () => { calls++; return calls === 1 ? pulls(100) : pulls(1); }, '/repos/o/r/pulls?state=all'); + assert.equal(rows.length, 101); + assert.equal(calls, 2); + await assert.rejects(listPages(async () => pulls(100), '/repos/o/r/pulls?state=all'), /Pagination limit/); +}); + +test('permission errors fail safely; outsider404 permits moderation', async () => { + for (const status of [403, 404]) { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (route.includes('/collaborators/')) throw Object.assign(new Error('permission lookup failed'), { status }); + return original(method, route, body); + }; + if (status === 403) await assert.rejects(moderate(f.args), /permission lookup/); + else assert.equal((await moderate(f.args)).count, 6); + } +}); + +test('closure failure still creates an alert with enforcement errors', async () => { + const f = fixture(6); + const original = f.args.api; + f.args.api = (method, route, body) => { + if (method === 'PATCH' && route.endsWith('/pulls/6')) throw new Error('close denied'); + return original(method, route, body); + }; + assert.match((await moderate(f.args)).errors[0], /close denied/); + assert(f.calls.some(c => c.method === 'POST' && c.body.body.includes('close denied'))); +}); + +test('reports only moderation metadata, never PR bodies or titles', async () => { + const f = fixture(6, { rows: pulls(6).map(p => ({ ...p, title: 'untrusted title', body: 'untrusted body' })) }); + assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/); +});