Skip to content

Commit 78b415c

Browse files
authored
Merge pull request #2521 from trycompai/feat/github-2fa-check
feat(github): Add 2FA enforcement check
2 parents a6d2625 + 30d09c4 commit 78b415c

File tree

3 files changed

+248
-1
lines changed

3 files changed

+248
-1
lines changed

packages/integration-platform/src/manifests/github/checks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
export { branchProtectionCheck } from './branch-protection';
77
export { dependabotCheck } from './dependabot';
88
export { sanitizedInputsCheck } from './sanitized-inputs';
9+
export { twoFactorAuthCheck } from './two-factor-auth';
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Two-Factor Authentication Check
3+
* Verifies that all organization members have 2FA enabled.
4+
*
5+
* Uses GET /orgs/{org}/members?filter=2fa_disabled to find members
6+
* without 2FA. The filter is only available to organization owners.
7+
*
8+
* @see https://docs.github.com/en/rest/orgs/members#list-organization-members
9+
*/
10+
11+
import { TASK_TEMPLATES } from '../../../task-mappings';
12+
import type { IntegrationCheck } from '../../../types';
13+
import type { GitHubOrg } from '../types';
14+
15+
interface GitHubOrgMember {
16+
login: string;
17+
id: number;
18+
html_url: string;
19+
}
20+
21+
const getHttpStatus = (error: unknown): number | null => {
22+
if (
23+
typeof error === 'object' &&
24+
error !== null &&
25+
'status' in error &&
26+
typeof (error as { status?: unknown }).status === 'number'
27+
) {
28+
return (error as { status: number }).status;
29+
}
30+
return null;
31+
};
32+
33+
const isOwnerPermissionError = (error: unknown, errorMsg: string): boolean => {
34+
const status = getHttpStatus(error);
35+
const lower = errorMsg.toLowerCase();
36+
37+
// GitHub documents 422 when 2fa_* filters are used in unsupported contexts.
38+
if (status === 422) return true;
39+
40+
if (lower.includes('must be an organization owner') || lower.includes('organization owners')) {
41+
return true;
42+
}
43+
44+
if (
45+
lower.includes('422') ||
46+
lower.includes('unprocessable') ||
47+
lower.includes('validation failed')
48+
) {
49+
return true;
50+
}
51+
52+
return false;
53+
};
54+
55+
const isSamlSsoError = (errorMsg: string): boolean => {
56+
const lower = errorMsg.toLowerCase();
57+
return lower.includes('saml') || lower.includes('single sign-on') || lower.includes('sso');
58+
};
59+
60+
const isRateLimitError = (error: unknown, errorMsg: string): boolean => {
61+
const status = getHttpStatus(error);
62+
const lower = errorMsg.toLowerCase();
63+
64+
return (
65+
status === 429 ||
66+
lower.includes('rate limit') ||
67+
lower.includes('abuse detection') ||
68+
(status === 403 && lower.includes('secondary rate limit'))
69+
);
70+
};
71+
72+
const formatUsernames = (members: GitHubOrgMember[]): string =>
73+
members.map((member) => `@${member.login}`).join(', ');
74+
75+
export const twoFactorAuthCheck: IntegrationCheck = {
76+
id: 'two_factor_auth',
77+
name: '2FA Enforcement',
78+
description:
79+
'Verify that all GitHub organization members have two-factor authentication enabled',
80+
service: 'code-security',
81+
taskMapping: TASK_TEMPLATES.twoFactorAuth,
82+
defaultSeverity: 'high',
83+
84+
run: async (ctx) => {
85+
// Step 1: Get all orgs the authenticated user belongs to
86+
let orgs: GitHubOrg[];
87+
try {
88+
orgs = await ctx.fetchAllPages<GitHubOrg>('/user/orgs');
89+
} catch (error) {
90+
const errorMsg = error instanceof Error ? error.message : String(error);
91+
ctx.error(`Failed to fetch organizations: ${errorMsg}`);
92+
ctx.fail({
93+
title: 'Cannot fetch GitHub organizations',
94+
description: `Failed to list organizations: ${errorMsg}`,
95+
resourceType: 'organization',
96+
resourceId: 'github',
97+
severity: 'medium',
98+
remediation:
99+
'Ensure the GitHub integration has the read:org scope. You may need to reconnect the integration.',
100+
});
101+
return;
102+
}
103+
104+
if (orgs.length === 0) {
105+
ctx.fail({
106+
title: 'No GitHub organizations found',
107+
description:
108+
'The connected GitHub account is not a member of any organizations. 2FA enforcement is an organization-level setting.',
109+
resourceType: 'organization',
110+
resourceId: 'github',
111+
severity: 'low',
112+
remediation:
113+
'Connect a GitHub account that belongs to at least one organization.',
114+
});
115+
return;
116+
}
117+
118+
ctx.log(`Found ${orgs.length} organization(s). Checking 2FA status...`);
119+
120+
// Step 2: For each org, check for members without 2FA
121+
for (const org of orgs) {
122+
ctx.log(`Checking 2FA for organization: ${org.login}`);
123+
const orgSlug = encodeURIComponent(org.login);
124+
const checkedAt = new Date().toISOString();
125+
126+
let membersWithout2FA: GitHubOrgMember[];
127+
try {
128+
membersWithout2FA = await ctx.fetchAllPages<GitHubOrgMember>(
129+
`/orgs/${orgSlug}/members?filter=2fa_disabled`,
130+
);
131+
} catch (error) {
132+
const errorMsg = error instanceof Error ? error.message : String(error);
133+
134+
if (isSamlSsoError(errorMsg)) {
135+
ctx.warn(`Cannot check 2FA for ${org.login}: SSO authorization is required.`);
136+
ctx.fail({
137+
title: `Cannot verify 2FA for ${org.login}`,
138+
description:
139+
'GitHub organization SSO authorization is required to access organization members.',
140+
resourceType: 'organization',
141+
resourceId: org.login,
142+
severity: 'medium',
143+
remediation:
144+
'Authorize this OAuth app for your organization SSO, then rerun the check.',
145+
});
146+
continue;
147+
}
148+
149+
if (isRateLimitError(error, errorMsg)) {
150+
ctx.warn(`Rate limit reached while checking 2FA for ${org.login}.`);
151+
ctx.fail({
152+
title: `Rate limited while checking ${org.login}`,
153+
description:
154+
'GitHub rate limits prevented completion of this 2FA check for the organization.',
155+
resourceType: 'organization',
156+
resourceId: org.login,
157+
severity: 'low',
158+
remediation: 'Wait for the GitHub rate limit to reset, then rerun the check.',
159+
});
160+
continue;
161+
}
162+
163+
// GitHub returns 422 when the caller is not an org owner for 2fa_* filters.
164+
if (isOwnerPermissionError(error, errorMsg)) {
165+
ctx.warn(
166+
`Cannot check 2FA for ${org.login}: the account must be an organization owner to use the 2FA filter.`,
167+
);
168+
ctx.fail({
169+
title: `Cannot verify 2FA for ${org.login}`,
170+
description:
171+
'Insufficient permissions to check 2FA status. The `filter=2fa_disabled` parameter is only available to organization owners on GitHub.',
172+
resourceType: 'organization',
173+
resourceId: org.login,
174+
severity: 'medium',
175+
remediation:
176+
'Reconnect the GitHub integration with an account that is an owner of this organization.',
177+
});
178+
continue;
179+
}
180+
181+
ctx.error(`Failed to check 2FA for ${org.login}: ${errorMsg}`);
182+
ctx.fail({
183+
title: `Error checking 2FA for ${org.login}`,
184+
description: `Failed to query members without 2FA: ${errorMsg}`,
185+
resourceType: 'organization',
186+
resourceId: org.login,
187+
severity: 'medium',
188+
remediation: 'Check the integration connection and try again.',
189+
});
190+
continue;
191+
}
192+
193+
const without2FACount = membersWithout2FA.length;
194+
195+
if (without2FACount === 0) {
196+
ctx.pass({
197+
title: `All members have 2FA enabled in ${org.login}`,
198+
description: `No members without 2FA were returned for ${org.login}.`,
199+
resourceType: 'organization',
200+
resourceId: org.login,
201+
evidence: {
202+
organization: org.login,
203+
membersWithout2FA: 0,
204+
checkedAt,
205+
},
206+
});
207+
} else {
208+
// List each member without 2FA as a separate finding
209+
for (const member of membersWithout2FA) {
210+
ctx.fail({
211+
title: `2FA not enabled: ${member.login}`,
212+
description: `GitHub user @${member.login} in the ${org.login} organization does not have two-factor authentication enabled.`,
213+
resourceType: 'user',
214+
resourceId: `${org.login}/${member.login}`,
215+
severity: 'high',
216+
remediation: `Ask @${member.login} to enable 2FA in their GitHub account settings (Settings > Password and authentication > Two-factor authentication). Alternatively, enforce 2FA at the organization level in ${org.login}'s settings.`,
217+
evidence: {
218+
organization: org.login,
219+
username: member.login,
220+
userId: member.id,
221+
profileUrl: member.html_url,
222+
checkedAt,
223+
},
224+
});
225+
}
226+
227+
// Also emit a summary
228+
ctx.fail({
229+
title: `${without2FACount} member(s) without 2FA in ${org.login}`,
230+
description: `${without2FACount} member(s) in the ${org.login} organization do not have two-factor authentication enabled: ${formatUsernames(membersWithout2FA)}`,
231+
resourceType: 'organization',
232+
resourceId: `${org.login}/2fa-summary`,
233+
severity: 'high',
234+
remediation: `1. Go to https://github.com/organizations/${org.login}/settings/security\n2. Under "Authentication security", check "Require two-factor authentication for everyone"\n3. This will require all existing and future members to enable 2FA`,
235+
evidence: {
236+
organization: org.login,
237+
membersWithout2FA: without2FACount,
238+
usernames: membersWithout2FA.map((member) => member.login),
239+
checkedAt,
240+
},
241+
});
242+
}
243+
}
244+
},
245+
};

packages/integration-platform/src/manifests/github/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { IntegrationManifest } from '../../types';
99
import { branchProtectionCheck } from './checks/branch-protection';
1010
import { dependabotCheck } from './checks/dependabot';
1111
import { sanitizedInputsCheck } from './checks/sanitized-inputs';
12+
import { twoFactorAuthCheck } from './checks/two-factor-auth';
1213

1314
export const manifest: IntegrationManifest = {
1415
id: 'github',
@@ -81,7 +82,7 @@ export const manifest: IntegrationManifest = {
8182
],
8283

8384
// Compliance checks that run daily and can auto-complete tasks
84-
checks: [branchProtectionCheck, dependabotCheck, sanitizedInputsCheck],
85+
checks: [branchProtectionCheck, dependabotCheck, sanitizedInputsCheck, twoFactorAuthCheck],
8586

8687
isActive: true,
8788
};

0 commit comments

Comments
 (0)