mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
Merge pull request #3596 from affaan-m/feat/pr-burst-protection
feat(ci): limit PR bursts and alert maintainers
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"windowMinutes": 20,
|
||||
"closeAbove": 5,
|
||||
"blockAbove": 10,
|
||||
"deniedUserIDs": [108231307],
|
||||
"notify": ["affaan-m", "haelyra"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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 = `<!-- ecc-pr-burst:${current.user.id}:${result.until.slice(0, 10)} -->`;
|
||||
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 = '<!-- ecc-pr-burst:block-threshold -->';
|
||||
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 };
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user