-
Notifications
You must be signed in to change notification settings - Fork 0
240 lines (216 loc) · 10.5 KB
/
coderabbit-security-gate.yml
File metadata and controls
240 lines (216 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
name: CodeRabbit Security Gate
# Blocks merges when CodeRabbit has unresolved Critical or Major review threads.
# A human can override by adding the `coderabbit-security-ok` label.
#
# Why this gate exists: CodeRabbit flagged 3 security issues across PRs #1613,
# #1623, and #1634 that were self-merged without human review. See issue #1649.
#
# How to override:
# 1. Review the CodeRabbit findings (resolve each thread or acknowledge it)
# 2. Add the `coderabbit-security-ok` label to the PR
# 3. The check will re-run and pass
#
# Note: The label must be added by a human — bots and claude-* actors are
# disallowed to prevent self-bypass.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
coderabbit-security-gate:
# Only re-run on label events when relevant label changes
if: >-
github.event.action != 'labeled' && github.event.action != 'unlabeled' ||
github.event.label.name == 'coderabbit-security-ok'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: read
steps:
- name: Check for unresolved CodeRabbit critical/major findings
uses: actions/github-script@v7
with:
script: |
const OVERRIDE_LABEL = 'coderabbit-security-ok';
const COMMENT_MARKER = '<!-- coderabbit-security-gate -->';
// CodeRabbit appears as 'coderabbitai' in GraphQL but 'coderabbitai[bot]' in REST.
// Accept both to be resilient to API differences.
const CODERABBIT_LOGINS = new Set(['coderabbitai', 'coderabbitai[bot]']);
// Severity patterns that require dismissal
const BLOCKING_SEVERITY_RE = /🔴 Critical|🟠 Major/;
// Actors not allowed to apply the override label.
// Belt-and-suspenders: check actor.type === 'Bot' (covers ALL GitHub bots/apps),
// plus a [bot] suffix check for safety, plus explicit logins and name patterns.
const DISALLOWED_LABEL_ACTORS = new Set([
'github-actions[bot]',
'dependabot[bot]',
]);
const DISALLOWED_ACTOR_PATTERNS = [
/^claude-/i,
];
function isDisallowedActor(actor) {
const login = actor?.login ?? '';
const type = (actor?.type ?? '').toLowerCase();
// GitHub sets type='Bot' for all bot accounts and GitHub Apps
if (type === 'bot') return true;
// Belt-and-suspenders: [bot] suffix catches any bot the type check misses
if (/\[bot\]$/i.test(login)) return true;
if (DISALLOWED_LABEL_ACTORS.has(login)) return true;
return DISALLOWED_ACTOR_PATTERNS.some(p => p.test(login));
}
// --- 1. Fetch review threads via GraphQL (paginated) ---
// Pagination prevents false negatives on PRs with >100 review threads.
const query = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
isResolved
isOutdated
path
line
comments(first: 3) {
nodes {
author { login }
body
}
}
}
}
}
}
}`;
const threads = [];
let hasNextPage = true;
let afterCursor = null;
while (hasNextPage) {
const gqlResult = await github.graphql(query, {
owner: context.repo.owner,
name: context.repo.repo,
number: context.issue.number,
after: afterCursor,
});
const reviewThreads = gqlResult.repository.pullRequest.reviewThreads;
threads.push(...reviewThreads.nodes);
hasNextPage = reviewThreads.pageInfo.hasNextPage;
afterCursor = reviewThreads.pageInfo.endCursor;
}
// Find unresolved, non-outdated CodeRabbit threads with blocking severity
const blockingThreads = threads.filter(thread => {
if (thread.isResolved || thread.isOutdated) return false;
const firstComment = thread.comments.nodes[0];
if (!firstComment?.author?.login) return false;
if (!CODERABBIT_LOGINS.has(firstComment.author.login)) return false;
return BLOCKING_SEVERITY_RE.test(firstComment.body);
});
console.log(`Found ${threads.length} review threads total`);
console.log(`Blocking unresolved CodeRabbit threads: ${blockingThreads.length}`);
// --- 2. Check override label ---
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const hasOverrideLabel = labels.some(l => l.name === OVERRIDE_LABEL);
let labelAddedByHuman = false;
let labelActorName = null;
if (hasOverrideLabel) {
const events = await github.paginate(
github.rest.issues.listEvents,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
}
);
const labelEvents = events.filter(
e => e.event === 'labeled' && e.label?.name === OVERRIDE_LABEL
);
const mostRecent = labelEvents[labelEvents.length - 1];
if (mostRecent?.actor) {
labelActorName = mostRecent.actor.login;
// Pass the full actor object so isDisallowedActor can check actor.type
labelAddedByHuman = !isDisallowedActor(mostRecent.actor);
}
}
const overrideApproved = hasOverrideLabel && labelAddedByHuman;
// --- 3. Decide pass/fail ---
const shouldBlock = blockingThreads.length > 0 && !overrideApproved;
// --- 4. Build thread summary ---
const threadDetails = blockingThreads.slice(0, 10).map(thread => {
const comment = thread.comments.nodes[0];
// Extract severity from first line
const firstLine = comment.body.split('\n')[0] ?? '';
const excerpt = comment.body.slice(0, 300).replace(/\n+/g, ' ');
return `- **${thread.path}**${thread.line ? ` (line ${thread.line})` : ''}: ${firstLine}\n > ${excerpt}${comment.body.length > 300 ? '…' : ''}`;
}).join('\n');
// --- 5. Build comment ---
const statusIcon = shouldBlock ? '🛑' : (overrideApproved ? '✅' : '✅');
let statusText;
if (blockingThreads.length === 0) {
statusText = 'No unresolved Critical or Major CodeRabbit findings. Check passes.';
} else if (overrideApproved) {
statusText = `**Override accepted**: The \`${OVERRIDE_LABEL}\` label was added by \`${labelActorName}\`. A human has acknowledged the ${blockingThreads.length} finding(s) below.`;
} else if (hasOverrideLabel && !labelAddedByHuman) {
statusText = `**Action required**: The \`${OVERRIDE_LABEL}\` label was added by \`${labelActorName || 'unknown'}\`, which is not recognized as a human reviewer. A human must remove and re-add the label.`;
} else {
statusText = `**Action required**: This PR has ${blockingThreads.length} unresolved Critical/Major CodeRabbit finding(s). Either resolve each thread in the GitHub review interface, or add the \`${OVERRIDE_LABEL}\` label after reviewing and acknowledging each finding.`;
}
const body = [
COMMENT_MARKER,
`## ${statusIcon} CodeRabbit Security Gate`,
'',
statusText,
'',
blockingThreads.length > 0 ? [
`### Unresolved Critical/Major Findings (${blockingThreads.length})`,
'',
threadDetails,
'',
blockingThreads.length > 10 ? `_...and ${blockingThreads.length - 10} more_\n` : '',
].join('\n') : '',
'---',
'',
`> **How to resolve**: Mark each CodeRabbit thread as resolved in the "Files changed" tab, or add the \`${OVERRIDE_LABEL}\` label if the findings are false positives. See [#1649](https://github.com/quantified-uncertainty/longterm-wiki/issues/1649).`,
].filter(s => s !== '').join('\n');
// Post or update the gate comment
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
}
);
const existing = comments.find(c => c.body && c.body.includes(COMMENT_MARKER));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else if (blockingThreads.length > 0) {
// Only create the comment if there are findings (don't clutter clean PRs)
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
// Fail the check if blocking
if (shouldBlock) {
core.setFailed(
`${blockingThreads.length} unresolved Critical/Major CodeRabbit finding(s) must be resolved or acknowledged before merging. ` +
`Add the "${OVERRIDE_LABEL}" label after a human reviews each finding.`
);
} else {
console.log('CodeRabbit security gate passed.');
}