feat(ci): limit Issue bursts and open submissions per author (#3723)

Verified with 30 exact-head hosted checks, 31 combined PR and Issue tests, workflow security validation, and a live read-only Issue replay.
This commit is contained in:
haelyra authored and GitHub committed 2026-10-25 15:16:53 -04:00
1 parent 4c28a7be9e
commit 108ff18624
8 files changed
+476 -1

No files matched your search

@@ -0,0 +1,105 @@
#!/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 Issue workflow CLI caps a new Issue, handles bursts, and keeps credentials separate', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-issue-guard-'));
const mutations = [];
const start = Date.parse('2026-10-25T00:00:00Z');
const rows = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, state: 'open',
user: { id: 42, login: 'contributor' }, created_at: new Date(start + i * 3600000).toISOString(),
title: '::error::untrusted title', body: '$(untrusted body)' }));
let alert;
let permission = 'read';
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
let raw = '';
for await (const chunk of req) raw += chunk;
const body = raw ? JSON.parse(raw) : 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 (url.pathname.includes('/collaborators/')) return send({ permission });
if (req.method === 'GET' && /^\/repos\/owner\/repo\/issues\/\d+$/.test(url.pathname)) {
return send(rows.find(row => row.number === Number(url.pathname.split('/').pop())));
}
if (req.method === 'GET' && url.pathname.endsWith('/issues')) {
return send([...rows.filter(row => url.searchParams.get('state') !== 'open' || row.state === 'open'),
...(alert && url.searchParams.get('state') !== 'open' ? [alert] : [])].reverse());
}
if (req.method === 'POST' && url.pathname.endsWith('/issues')) {
alert = { number: 999, created_at: new Date(start).toISOString(),
user: { login: 'github-actions[bot]' }, ...body }; return send(alert);
}
if (req.method === 'GET' && url.pathname.endsWith('/comments')) return send([]);
if (req.method === 'PATCH' && url.pathname.endsWith('/issues/999')) {
alert = { ...alert, ...body }; return send(alert);
}
if (req.method === 'PATCH' && /^\/repos\/owner\/repo\/issues\/\d+$/.test(url.pathname)) {
const issue = rows.find(row => row.number === Number(url.pathname.split('/').pop()));
issue.state = body.state; return send(issue);
}
if (req.method === 'POST' && url.pathname.endsWith('/comments')) return send({});
if (req.method === 'PUT' && url.pathname.endsWith('/lock')) { res.statusCode = 204; return res.end(); }
if (req.method === 'PUT' && url.pathname.startsWith('/user/blocks/')) { res.statusCode = 204; return res.end(); }
res.statusCode = 404; send({ message: 'missing fixture route' });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const port = server.address().port;
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');
const summary = path.join(tmp, 'summary.md');
const env = { ...process.env, NODE_OPTIONS: `--require=${preload}`, GITHUB_REPOSITORY: 'owner/repo',
GITHUB_TOKEN: 'issue-token', PR_MODERATION_BLOCK_TOKEN: 'owner-token',
GITHUB_EVENT_PATH: event, GITHUB_STEP_SUMMARY: summary };
const run = args => exec(process.execPath, ['scripts/ci/issue-submission-guard.js', ...args], { cwd: root, env });
rows.push({ number: 21, state: 'open', user: { id: 42, login: 'contributor' },
created_at: new Date(start + 20 * 3600000).toISOString() });
fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 21 } }));
const cap = JSON.parse((await run([])).stdout);
assert.deepEqual(cap.capExcess.map(issue => issue.number), [21]);
assert.equal(mutations.filter(m => m.method === 'PATCH' && m.route === '/repos/owner/repo/issues/21').length, 1);
assert.equal(mutations.filter(m => m.route.endsWith('/lock')).length, 0);
assert.equal(mutations.filter(m => m.method === 'POST' && m.route.endsWith('/comments')).length, 1);
permission = 'write';
rows.push({ number: 22, state: 'open', user: { id: 42, login: 'contributor' },
created_at: new Date(start + 21 * 3600000).toISOString() });
fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 22 } }));
assert.equal(JSON.parse((await run([])).stdout).burstExempt, true);
assert.equal(mutations.filter(m => m.method === 'PATCH' && m.route === '/repos/owner/repo/issues/22').length, 1);
permission = 'read';
rows.splice(0, rows.length, ...Array.from({ length: 11 }, (_, i) => ({ number: i + 1,
state: 'open', user: { id: 42, login: 'contributor' },
created_at: new Date(start + i * 1000).toISOString() })));
fs.writeFileSync(event, JSON.stringify({ action: 'opened', issue: { number: 11 } }));
const burst = JSON.parse((await run([])).stdout);
assert.equal(burst.count, 11);
assert.match(burst.blockStatus, /^Blocked/);
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 issue-token'));
assert.doesNotMatch(fs.readFileSync(summary, 'utf8'), /untrusted|owner-token|issue-token/);
const before = mutations.length;
const dry = JSON.parse((await run(['--dry-run'])).stdout);
assert.equal(dry.block, true);
assert.equal(mutations.length, before);
} finally {
await new Promise(resolve => server.close(resolve));
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { capExcess, moderate } = require('../../scripts/ci/issue-submission-guard');
const policy = { windowMinutes: 20, closeAbove: 5, blockAbove: 10, maxOpen: 10,
deniedUserIDs: [], notify: ['maintainer'] };
const start = Date.parse('2026-10-25T00:00:00Z');
const issue = (number, extra = {}) => ({ number, state: 'open', user: { id: 42, login: 'contributor' },
created_at: new Date(start + number * 1000).toISOString(), ...extra });
const spread = n => Array.from({ length: n }, (_, i) => issue(i + 1,
{ created_at: new Date(start + i * 3600000).toISOString() }));
function fixture(number, options = {}) {
const rows = options.rows || Array.from({ length: number }, (_, i) => issue(i + 1));
const calls = [];
let alert;
const api = async (method, route, body) => {
calls.push({ method, route, body });
if (route === '/repos/o/r') return { owner: { type: 'User', login: 'o' } };
if (route === `/repos/o/r/issues/${number}` && method === 'GET') return rows.find(row => row.number === number);
if (route.includes('/collaborators/')) return { permission: options.permission || 'read' };
if (route.includes('/issues?')) return [
...rows.filter(row => !route.includes('state=open') || row.state === 'open'),
...(alert && !route.includes('state=open') ? [alert] : [])].reverse();
if (route.endsWith('/issues') && method === 'POST') {
alert = { number: 999, user: { login: 'github-actions[bot]' }, created_at: new Date(start).toISOString(), ...body };
return alert;
}
if (route.endsWith('/issues/999') && method === 'PATCH') { alert = { ...alert, ...body }; return alert; }
if (route.endsWith('/comments') && method === 'GET') return options.comments || [];
if (method === 'PATCH' || method === 'POST' || method === 'PUT') return {};
throw new Error(`Unexpected ${method} ${route}`);
};
return { args: { api, repository: 'o/r', number, policy, action: 'opened', ...options }, calls };
}
test('burst closes and locks the sixth Issue, excludes pull requests, and deduplicates bot alerts', async () => {
const rows = [...Array.from({ length: 6 }, (_, i) => issue(i + 1)),
issue(7, { pull_request: {}, user: { id: 42, login: 'contributor' } })];
const f = fixture(6, { rows });
const first = await moderate(f.args);
assert.equal(first.count, 6);
assert.deepEqual(first.excess.map(row => row.number), [6]);
await moderate(f.args);
assert.equal(f.calls.filter(call => call.method === 'POST' && call.route.endsWith('/issues')).length, 1);
assert.equal(f.calls.filter(call => call.method === 'PUT' && call.route.endsWith('/issues/6/lock')).length, 2);
assert.equal(f.calls.filter(call => call.route.endsWith('/issues/7/lock')).length, 0);
});
test('severe burst verifies owner identity and reports missing block credential', async () => {
const f = fixture(11);
const result = await moderate(f.args);
assert.equal(result.block, true);
assert.match(result.blockStatus, /not configured/);
const ownerCalls = [];
const g = fixture(11, { blockAPI: async (method, route) => {
ownerCalls.push([method, route]);
return route === '/user' ? { login: 'o' } : {};
} });
assert.match((await moderate(g.args)).blockStatus, /^Blocked/);
assert(ownerCalls.some(([method, route]) => method === 'PUT' && route === '/user/blocks/contributor'));
});
test('cap preserves twenty old open Issues, closes only a new Issue, and applies to writers', async () => {
const rows = spread(21);
for (const permission of ['read', 'write']) {
const f = fixture(21, { rows, permission });
const result = await moderate(f.args);
assert.deepEqual(result.capExcess.map(row => row.number), [21]);
assert.deepEqual(f.calls.filter(call => call.method === 'PATCH').map(call => call.route), ['/repos/o/r/issues/21']);
assert.equal(f.calls.filter(call => call.method === 'PUT').length, 0);
assert.equal(result.burstExempt, permission === 'write');
}
assert.deepEqual(capExcess(rows, rows[20], policy).capExcess.map(row => row.number), [21]);
});
test('reopened old Issue consumes a slot without closing newer peers', async () => {
const rows = spread(11);
const f = fixture(1, { rows, action: 'reopened', now: start + 24 * 3600000 });
const result = await moderate(f.args);
assert.deepEqual(result.capExcess.map(row => row.number), [1]);
assert.equal(result.count, 0);
assert.deepEqual(f.calls.filter(call => call.method === 'PATCH').map(call => call.route), ['/repos/o/r/issues/1']);
});
test('out-of-order opened run closes newer excess Issues', async () => {
const rows = spread(12);
assert.deepEqual(capExcess(rows, rows[10], policy).capExcess.map(row => row.number), [11, 12]);
});
test('dry run does not mutate, and bot alerts plus pull requests skip moderation', async () => {
const dry = fixture(11, { dryRun: true, blockAPI: () => { throw new Error('secret used'); } });
assert.equal((await moderate(dry.args)).block, true);
assert(dry.calls.every(call => call.method === 'GET'));
for (const extra of [{ pull_request: {} }, { user: { id: 1, login: 'github-actions[bot]' } }]) {
const f = fixture(1, { rows: [issue(1, extra)] });
assert((await moderate(f.args)).skipped);
assert.equal(f.calls.length, 2);
}
});
test('permission failures do not silently exempt outsiders, and summaries omit contributor text', async () => {
const f = fixture(6, { rows: Array.from({ length: 6 }, (_, i) => issue(i + 1,
{ title: 'untrusted title', body: 'untrusted body' })) });
const original = f.args.api;
f.args.api = (method, route, body) => route.includes('/collaborators/')
? Promise.reject(Object.assign(new Error('lookup failed'), { status: 403 })) : original(method, route, body);
await assert.rejects(moderate(f.args), /lookup failed/);
f.args.api = original;
assert.doesNotMatch(JSON.stringify(await moderate(f.args)), /untrusted/);
});