audit-labs/audit-tools
A collection of scripts, queries, and other goodies you can use in an audit.
clone: git clone https://gitbay.org/audit-labs/audit-tools.git
v1.0.0: applications/github/collectors/members.py · raw
1"""
2Collect org membership, access, and permission data.
3
4Covers:
5- Org member roster with roles (owner vs member)
6- Outside collaborators
7- Privileged access (admin permission on any repo)
8- Pending org invitations
9- Team memberships and repo permissions
10- Full per-repo permission matrix
11- Members with 2FA disabled
12"""
13
14import sys
15from datetime import datetime, timezone
16
17import requests
18
19from .api import paginate
20
21
22def _permission_level(perms):
23 for level in ("admin", "maintain", "push", "triage", "pull"):
24 if perms.get(level):
25 return "write" if level == "push" else level
26 return "unknown"
27
28
29def _repos(org, cfg):
30 return paginate(f"https://api.github.com/orgs/{org}/repos", cfg)
31
32
33def fetch_repo_collaborators(org, cfg):
34 """
35 Fetch collaborators for every repo once (affiliation=all).
36 Returns a list of dicts: {repo, visibility, collaborators}.
37 Repos that 403 are skipped with a warning.
38 This cache is passed into outside_collaborators, privileged_access,
39 and permission_matrix to avoid redundant API calls.
40 """
41 repos = _repos(org, cfg)
42 results = []
43 for repo in repos:
44 repo_name = repo["name"]
45 try:
46 collabs = paginate(
47 f"https://api.github.com/repos/{org}/{repo_name}/collaborators",
48 cfg,
49 {"affiliation": "all"},
50 )
51 except requests.HTTPError as e:
52 if e.response is not None and e.response.status_code == 403:
53 print(
54 f" Skipping {repo_name}: collaborators endpoint returned 403",
55 file=sys.stderr,
56 )
57 continue
58 raise
59 results.append(
60 {
61 "repo": repo_name,
62 "visibility": repo["visibility"],
63 "collaborators": collabs,
64 }
65 )
66 return results
67
68
69def member_roster(org, cfg):
70 members = paginate(
71 f"https://api.github.com/orgs/{org}/members", cfg, {"role": "all"}
72 )
73 owners = {
74 m["login"]
75 for m in paginate(
76 f"https://api.github.com/orgs/{org}/members", cfg, {"role": "owner"}
77 )
78 }
79 return [
80 {
81 "login": m["login"],
82 "org_role": "owner" if m["login"] in owners else "member",
83 "profile_url": m["html_url"],
84 }
85 for m in members
86 ]
87
88
89def two_factor_disabled(org, cfg):
90 """
91 List org members who do not have 2FA enabled.
92 Requires the token to have read:org scope.
93 Note: GitHub only exposes this to org owners.
94 """
95 members = paginate(
96 f"https://api.github.com/orgs/{org}/members",
97 cfg,
98 {"filter": "2fa_disabled"},
99 )
100 return [
101 {
102 "login": m["login"],
103 "profile_url": m["html_url"],
104 }
105 for m in members
106 ]
107
108
109def outside_collaborators(org, cfg, repo_collabs):
110 """
111 Non-org members with direct repo access.
112 Accepts pre-fetched repo_collabs from fetch_repo_collaborators().
113 """
114 outside = {
115 m["login"]
116 for m in paginate(
117 f"https://api.github.com/orgs/{org}/outside_collaborators", cfg
118 )
119 }
120 rows = []
121 for entry in repo_collabs:
122 for c in entry["collaborators"]:
123 if c["login"] in outside:
124 rows.append(
125 {
126 "login": c["login"],
127 "repo": entry["repo"],
128 "permission": _permission_level(c.get("permissions", {})),
129 "repo_visibility": entry["visibility"],
130 }
131 )
132 return rows
133
134
135def privileged_access(_org, _cfg, repo_collabs):
136 """
137 All users with admin permission on any repo.
138 Accepts pre-fetched repo_collabs from fetch_repo_collaborators().
139 """
140 rows = []
141 for entry in repo_collabs:
142 for c in entry["collaborators"]:
143 if c.get("permissions", {}).get("admin"):
144 rows.append(
145 {
146 "login": c["login"],
147 "repo": entry["repo"],
148 "permission": "admin",
149 "repo_visibility": entry["visibility"],
150 }
151 )
152 return rows
153
154
155def pending_invitations(org, cfg):
156 now = datetime.now(timezone.utc)
157 rows = []
158 for inv in paginate(f"https://api.github.com/orgs/{org}/invitations", cfg):
159 created = inv.get("created_at", "")
160 age_days = None
161 if created:
162 dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
163 age_days = (now - dt).days
164 rows.append(
165 {
166 "login": inv.get("login") or inv.get("email", "unknown"),
167 "role": inv.get("role", ""),
168 "invited_by": inv.get("inviter", {}).get("login", ""),
169 "created_at": created,
170 "age_days": age_days,
171 }
172 )
173 return rows
174
175
176def team_permissions(org, cfg):
177 rows = []
178 for team in paginate(f"https://api.github.com/orgs/{org}/teams", cfg):
179 slug = team["slug"]
180 team_members = paginate(
181 f"https://api.github.com/orgs/{org}/teams/{slug}/members", cfg
182 )
183 team_repos = paginate(
184 f"https://api.github.com/orgs/{org}/teams/{slug}/repos", cfg
185 )
186 member_logins = ", ".join(m["login"] for m in team_members) or "(none)"
187 for repo in team_repos:
188 rows.append(
189 {
190 "team": team["name"],
191 "repo": repo["name"],
192 "permission": _permission_level(repo.get("permissions", {})),
193 "members": member_logins,
194 }
195 )
196 return rows
197
198
199def permission_matrix(_org, _cfg, repo_collabs):
200 """
201 Full per-repo/per-user permission cross-reference.
202 Accepts pre-fetched repo_collabs from fetch_repo_collaborators().
203 """
204 rows = []
205 for entry in repo_collabs:
206 for c in entry["collaborators"]:
207 rows.append(
208 {
209 "repo": entry["repo"],
210 "login": c["login"],
211 "permission": _permission_level(c.get("permissions", {})),
212 "visibility": entry["visibility"],
213 }
214 )
215 return rows