From 0ad248d199fbe9e5bb9f19857b02d3fd9f1ed887 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:33:45 -0400 Subject: [PATCH 1/2] feat(ci): guard PR submission bursts and alert maintainers --- .github/PR_MODERATION.md | 140 ++++++++++++++++ .github/pr-burst-policy.json | 7 + .github/workflows/pr-burst-guard.yml | 39 +++++ CONTRIBUTING.md | 12 ++ scripts/ci/pr-burst-guard.js | 199 +++++++++++++++++++++++ tests/ci/pr-burst-guard-workflow.test.js | 87 ++++++++++ tests/ci/pr-burst-guard.test.js | 184 +++++++++++++++++++++ 7 files changed, 668 insertions(+) create mode 100644 .github/PR_MODERATION.md create mode 100644 .github/pr-burst-policy.json create mode 100644 .github/workflows/pr-burst-guard.yml create mode 100644 scripts/ci/pr-burst-guard.js create mode 100644 tests/ci/pr-burst-guard-workflow.test.js create mode 100644 tests/ci/pr-burst-guard.test.js diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md new file mode 100644 index 000000000..c87f7cde8 --- /dev/null +++ b/.github/PR_MODERATION.md @@ -0,0 +1,140 @@ +# 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 | +| --- | --- | --- | +| Native concurrent cap | Five open PRs, including drafts | Admin must enable GitHub setting | +| 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. A native cap bypass list does not bypass the custom burst +guard. Granting write access is not required for native cap exceptions. + +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. +The native concurrent cap supplies the pre-creation control. 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. As a repository administrator, enable the native cap under **Settings > + Moderation options > Interaction limits > Pull request limits**. Set five + concurrent PRs and include drafts. Alternatively, authenticate `gh` as an + administrator and run: + + ```sh + gh api --method PATCH repos/affaan-m/ECC/interaction-limits/pulls/creation-cap \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + -F enabled=true -F max_open_pull_requests=5 -F include_drafts=true + gh api repos/affaan-m/ECC/interaction-limits/pulls/creation-cap + ``` + +3. 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. +4. 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. + +GitHub's native cap counts concurrent open PRs. The custom guard counts creations +over time. Closing PRs can free native cap slots, but cannot reset burst counts. +A native cap can therefore stop a contributor before they reach the severe +threshold; the custom guard still catches rapid closing and resubmission. + +## 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. Disable the native cap separately in Settings. +- 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 currently open `dajiaohuang` PRs. 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. No historical PR was closed or account blocked during this +investigation. The native cap API returned `403: Must have admin rights to +Repository` for the authenticated collaborator `haelyra`. + +This advances M0 maintenance reliability. Runtime sandbox, Capsule, Fleet, +release publication and shared application installation remain separate work. + +## GitHub references + +- [Native repository PR caps and bypass lists](https://docs.github.com/en/communities/moderating-comments-and-conversations/limiting-interactions-in-your-repository) +- [Repository cap REST endpoints](https://docs.github.com/en/rest/interactions/repos) +- [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..71c844f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -433,6 +433,18 @@ 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. + +The repository also supports GitHub's native concurrent open-PR cap, configured +separately by an administrator. 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/); +}); From 0cf3a25d57a64aca27d00495b0ff7891c545613f Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:16:13 -0400 Subject: [PATCH 2/2] docs(ci): keep PR moderation limited to submission rate --- .github/PR_MODERATION.md | 42 ++++++++++++---------------------------- CONTRIBUTING.md | 3 +-- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/.github/PR_MODERATION.md b/.github/PR_MODERATION.md index c87f7cde8..658327570 100644 --- a/.github/PR_MODERATION.md +++ b/.github/PR_MODERATION.md @@ -8,7 +8,6 @@ judge whether a patch is useful. Existing open PRs are not bulk-closed on rollou | Control | Default | Enforcement | | --- | --- | --- | -| Native concurrent cap | Five open PRs, including drafts | Admin must enable GitHub setting | | 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 | @@ -16,8 +15,8 @@ judge whether a patch is useful. Existing open PRs are not bulk-closed on rollou | 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. A native cap bypass list does not bypass the custom burst -guard. Granting write access is not required for native cap exceptions. +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 @@ -30,31 +29,18 @@ 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. -The native concurrent cap supplies the pre-creation control. Actions moderation -happens after creation and cannot prevent every CI job from starting. Account +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. As a repository administrator, enable the native cap under **Settings > - Moderation options > Interaction limits > Pull request limits**. Set five - concurrent PRs and include drafts. Alternatively, authenticate `gh` as an - administrator and run: - - ```sh - gh api --method PATCH repos/affaan-m/ECC/interaction-limits/pulls/creation-cap \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - -F enabled=true -F max_open_pull_requests=5 -F include_drafts=true - gh api repos/affaan-m/ECC/interaction-limits/pulls/creation-cap - ``` - -3. To enable automatic account blocking, `affaan-m` creates a dedicated +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. -4. Verify a genuine subsequent guard run and its job summary. Without the secret, +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. @@ -65,10 +51,8 @@ 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. -GitHub's native cap counts concurrent open PRs. The custom guard counts creations -over time. Closing PRs can free native cap slots, but cannot reset burst counts. -A native cap can therefore stop a contributor before they reach the severe -threshold; the custom guard still catches rapid closing and resubmission. +Closing PRs cannot reset burst counts. Rapid closing and resubmission still +count as creations within the same rolling window. ## Verify without moderating anyone @@ -109,14 +93,14 @@ verification; the local fixture cannot certify those platform operations. 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. Disable the native cap separately in Settings. + 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 currently open `dajiaohuang` PRs. Their latest update was +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 @@ -125,16 +109,14 @@ 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. No historical PR was closed or account blocked during this -investigation. The native cap API returned `403: Must have admin rights to -Repository` for the authenticated collaborator `haelyra`. +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 -- [Native repository PR caps and bypass lists](https://docs.github.com/en/communities/moderating-comments-and-conversations/limiting-interactions-in-your-repository) -- [Repository cap REST endpoints](https://docs.github.com/en/rest/interactions/repos) - [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/CONTRIBUTING.md b/CONTRIBUTING.md index 71c844f8f..bd8e3d96b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -441,8 +441,7 @@ closes and locks excess PRs, including drafts. More than ten submissions within still count toward the window. Contributors with repository write access bypass this policy. Maintainers can review exceptions through the moderation alert. -The repository also supports GitHub's native concurrent open-PR cap, configured -separately by an administrator. See the [moderation runbook](.github/PR_MODERATION.md) +See the [moderation runbook](.github/PR_MODERATION.md) for activation, credential requirements, and recovery. ### 1. PR Title Format