Commit 2dc0c40b11
Unsigned
Layout: unified · split
.github/workflows/ruff.yml +6 −16
| @@ -12,26 +12,16 @@ jobs: | ||
| 12 | 12 | matrix: |
| 13 | 13 | python-version: ["3.x"] |
| 14 | 14 | steps: |
| 15 | - uses: actions/checkout@v4 | |
| 15 | - uses: actions/checkout@v5 | |
| 16 | 16 | - name: Set up Python ${{ matrix.python-version }} |
| 17 | uses: actions/setup-python@v5 | |
| 17 | uses: actions/setup-python@v6 | |
| 18 | 18 | with: |
| 19 | 19 | python-version: ${{ matrix.python-version }} |
| 20 | ref: ${{ github.event.pull_request.head.ref }} | |
| 21 | 20 | - name: Install dependencies |
| 22 | 21 | run: | |
| 23 | 22 | python -m pip install --upgrade pip |
| 24 | pip install pandas dash plotly.express | |
| 25 | - name: Install Ruff | |
| 26 | uses: astral-sh/ruff-action@v3.2.2 | |
| 27 | - name: Ruff Actions | |
| 23 | pip install pandas dash plotly.express ruff | |
| 24 | - name: Ruff | |
| 28 | 25 | run: | |
| 29 | ruff check --fix | |
| 30 | ruff format | |
| 31 | - name: Add and Commit | |
| 32 | uses: EndBug/add-and-commit@v9 | |
| 33 | env: | |
| 34 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 35 | with: | |
| 36 | default_author: github_actions | |
| 37 | pathspec_error_handling: ignore | |
| 26 | ruff check . | |
| 27 | ruff format --check . | |
applications/github/audit.py +53 −10
| @@ -90,8 +90,10 @@ def run(): | ||
| 90 | 90 | sections.append((label, len(rows))) |
| 91 | 91 | return rows |
| 92 | 92 | |
| 93 | collect("Member roster", members.member_roster, "member_roster.csv", org, cfg) | |
| 94 | collect("2FA disabled", members.two_factor_disabled, "two_factor_disabled.csv", org, cfg) | |
| 93 | collect("Member roster", members.member_roster, "member_roster.csv", org, cfg) | |
| 94 | collect( | |
| 95 | "2FA disabled", members.two_factor_disabled, "two_factor_disabled.csv", org, cfg | |
| 96 | ) | |
| 95 | 97 | |
| 96 | 98 | print("Fetching repo collaborators (shared cache)...") |
| 97 | 99 | try: |
| @@ -100,14 +102,55 @@ def run(): | ||
| 100 | 102 | print(f" Error fetching collaborators: {e}", file=sys.stderr) |
| 101 | 103 | repo_collabs = [] |
| 102 | 104 | |
| 103 | collect("Outside collaborators", members.outside_collaborators, "outside_collaborators.csv", org, cfg, repo_collabs) | |
| 104 | collect("Privileged access", members.privileged_access, "privileged_access.csv", org, cfg, repo_collabs) | |
| 105 | collect("Pending invitations", members.pending_invitations, "pending_invitations.csv", org, cfg) | |
| 106 | collect("Team permissions", members.team_permissions, "team_permissions.csv", org, cfg) | |
| 107 | collect("Permission matrix", members.permission_matrix, "permission_matrix.csv", org, cfg, repo_collabs) | |
| 108 | collect("Branch protections", branch_protections.branch_protections, "branch_protections.csv", org, cfg) | |
| 109 | collect("Commits", commits.commits, "commits.csv", org, cfg, args.branch) | |
| 110 | collect("Audit log branch/ruleset changes", audit_log.audit_log, "audit_log.csv", org, cfg) | |
| 105 | collect( | |
| 106 | "Outside collaborators", | |
| 107 | members.outside_collaborators, | |
| 108 | "outside_collaborators.csv", | |
| 109 | org, | |
| 110 | cfg, | |
| 111 | repo_collabs, | |
| 112 | ) | |
| 113 | collect( | |
| 114 | "Privileged access", | |
| 115 | members.privileged_access, | |
| 116 | "privileged_access.csv", | |
| 117 | org, | |
| 118 | cfg, | |
| 119 | repo_collabs, | |
| 120 | ) | |
| 121 | collect( | |
| 122 | "Pending invitations", | |
| 123 | members.pending_invitations, | |
| 124 | "pending_invitations.csv", | |
| 125 | org, | |
| 126 | cfg, | |
| 127 | ) | |
| 128 | collect( | |
| 129 | "Team permissions", members.team_permissions, "team_permissions.csv", org, cfg | |
| 130 | ) | |
| 131 | collect( | |
| 132 | "Permission matrix", | |
| 133 | members.permission_matrix, | |
| 134 | "permission_matrix.csv", | |
| 135 | org, | |
| 136 | cfg, | |
| 137 | repo_collabs, | |
| 138 | ) | |
| 139 | collect( | |
| 140 | "Branch protections", | |
| 141 | branch_protections.branch_protections, | |
| 142 | "branch_protections.csv", | |
| 143 | org, | |
| 144 | cfg, | |
| 145 | ) | |
| 146 | collect("Commits", commits.commits, "commits.csv", org, cfg, args.branch) | |
| 147 | collect( | |
| 148 | "Audit log branch/ruleset changes", | |
| 149 | audit_log.audit_log, | |
| 150 | "audit_log.csv", | |
| 151 | org, | |
| 152 | cfg, | |
| 153 | ) | |
| 111 | 154 | |
| 112 | 155 | print() |
| 113 | 156 | csv_reporter.write_summary(output_dir, org, sections) |
applications/github/collectors/api.py +3 −1
| @@ -12,7 +12,9 @@ def paginate(url, cfg, params=None): | ||
| 12 | 12 | |
| 13 | 13 | while True: |
| 14 | 14 | p["page"] = page |
| 15 | resp = requests.get(url, headers=cfg["headers"], params=p, timeout=cfg["timeout"]) | |
| 15 | resp = requests.get( | |
| 16 | url, headers=cfg["headers"], params=p, timeout=cfg["timeout"] | |
| 17 | ) | |
| 16 | 18 | resp.raise_for_status() |
| 17 | 19 | data = resp.json() |
| 18 | 20 | if not data: |
applications/github/collectors/audit_log.py +13 −11
| @@ -88,17 +88,19 @@ def audit_log(org, cfg, actions=None, lookback_days=DEFAULT_LOOKBACK_DAYS): | ||
| 88 | 88 | |
| 89 | 89 | rows = [] |
| 90 | 90 | for e in _dedupe_events(events): |
| 91 | rows.append({ | |
| 92 | "action": e.get("action", ""), | |
| 93 | "actor": e.get("actor", ""), | |
| 94 | "repo": e.get("repo", ""), | |
| 95 | "branch_or_pattern": _branch_or_pattern(e), | |
| 96 | "operation_type": e.get("operation_type", ""), | |
| 97 | "summary": _event_summary(e), | |
| 98 | "details": _event_details(e), | |
| 99 | "created_at": _format_created_at(e.get("created_at", "")), | |
| 100 | "org": e.get("org", ""), | |
| 101 | }) | |
| 91 | rows.append( | |
| 92 | { | |
| 93 | "action": e.get("action", ""), | |
| 94 | "actor": e.get("actor", ""), | |
| 95 | "repo": e.get("repo", ""), | |
| 96 | "branch_or_pattern": _branch_or_pattern(e), | |
| 97 | "operation_type": e.get("operation_type", ""), | |
| 98 | "summary": _event_summary(e), | |
| 99 | "details": _event_details(e), | |
| 100 | "created_at": _format_created_at(e.get("created_at", "")), | |
| 101 | "org": e.get("org", ""), | |
| 102 | } | |
| 103 | ) | |
| 102 | 104 | return rows |
| 103 | 105 | |
| 104 | 106 | |
applications/github/collectors/branch_protections.py +33 −23
| @@ -27,7 +27,10 @@ def branch_protections(org, cfg): | ||
| 27 | 27 | ) |
| 28 | 28 | except requests.HTTPError as e: |
| 29 | 29 | if e.response is not None and e.response.status_code == 403: |
| 30 | print(f" Skipping {repo_name}: branches endpoint returned 403", file=sys.stderr) | |
| 30 | print( | |
| 31 | f" Skipping {repo_name}: branches endpoint returned 403", | |
| 32 | file=sys.stderr, | |
| 33 | ) | |
| 31 | 34 | continue |
| 32 | 35 | raise |
| 33 | 36 | |
| @@ -40,17 +43,19 @@ def branch_protections(org, cfg): | ||
| 40 | 43 | resp = requests.get(url, headers=cfg["headers"], timeout=cfg["timeout"]) |
| 41 | 44 | |
| 42 | 45 | if resp.status_code in (403, 404): |
| 43 | rows.append({ | |
| 44 | "repo": repo_name, | |
| 45 | "branch": branch_name, | |
| 46 | "protected": False, | |
| 47 | "required_reviews": None, | |
| 48 | "dismiss_stale_reviews": None, | |
| 49 | "require_code_owner_reviews": None, | |
| 50 | "required_status_checks": None, | |
| 51 | "enforce_admins": None, | |
| 52 | "restrictions": None, | |
| 53 | }) | |
| 46 | rows.append( | |
| 47 | { | |
| 48 | "repo": repo_name, | |
| 49 | "branch": branch_name, | |
| 50 | "protected": False, | |
| 51 | "required_reviews": None, | |
| 52 | "dismiss_stale_reviews": None, | |
| 53 | "require_code_owner_reviews": None, | |
| 54 | "required_status_checks": None, | |
| 55 | "enforce_admins": None, | |
| 56 | "restrictions": None, | |
| 57 | } | |
| 58 | ) | |
| 54 | 59 | continue |
| 55 | 60 | |
| 56 | 61 | resp.raise_for_status() |
| @@ -58,16 +63,21 @@ def branch_protections(org, cfg): | ||
| 58 | 63 | reviews = p.get("required_pull_request_reviews", {}) |
| 59 | 64 | checks = p.get("required_status_checks", {}) |
| 60 | 65 | |
| 61 | rows.append({ | |
| 62 | "repo": repo_name, | |
| 63 | "branch": branch_name, | |
| 64 | "protected": True, | |
| 65 | "required_reviews": reviews.get("required_approving_review_count"), | |
| 66 | "dismiss_stale_reviews": reviews.get("dismiss_stale_reviews"), | |
| 67 | "require_code_owner_reviews": reviews.get("require_code_owner_reviews"), | |
| 68 | "required_status_checks": ", ".join(checks.get("contexts", [])) or None, | |
| 69 | "enforce_admins": p.get("enforce_admins", {}).get("enabled"), | |
| 70 | "restrictions": bool(p.get("restrictions")), | |
| 71 | }) | |
| 66 | rows.append( | |
| 67 | { | |
| 68 | "repo": repo_name, | |
| 69 | "branch": branch_name, | |
| 70 | "protected": True, | |
| 71 | "required_reviews": reviews.get("required_approving_review_count"), | |
| 72 | "dismiss_stale_reviews": reviews.get("dismiss_stale_reviews"), | |
| 73 | "require_code_owner_reviews": reviews.get( | |
| 74 | "require_code_owner_reviews" | |
| 75 | ), | |
| 76 | "required_status_checks": ", ".join(checks.get("contexts", [])) | |
| 77 | or None, | |
| 78 | "enforce_admins": p.get("enforce_admins", {}).get("enabled"), | |
| 79 | "restrictions": bool(p.get("restrictions")), | |
| 80 | } | |
| 81 | ) | |
| 72 | 82 | |
| 73 | 83 | return rows |
applications/github/collectors/commits.py +13 −11
| @@ -31,16 +31,18 @@ def commits(org, cfg, branch="main"): | ||
| 31 | 31 | commit = c.get("commit", {}) |
| 32 | 32 | author = commit.get("author", {}) |
| 33 | 33 | stats = c.get("stats", {}) |
| 34 | rows.append({ | |
| 35 | "repo": repo_name, | |
| 36 | "branch": branch, | |
| 37 | "sha": c.get("sha", "")[:12], | |
| 38 | "author_name": author.get("name", ""), | |
| 39 | "author_email": author.get("email", ""), | |
| 40 | "date": author.get("date", ""), | |
| 41 | "message": commit.get("message", "").splitlines()[0], | |
| 42 | "additions": stats.get("additions", ""), | |
| 43 | "deletions": stats.get("deletions", ""), | |
| 44 | }) | |
| 34 | rows.append( | |
| 35 | { | |
| 36 | "repo": repo_name, | |
| 37 | "branch": branch, | |
| 38 | "sha": c.get("sha", "")[:12], | |
| 39 | "author_name": author.get("name", ""), | |
| 40 | "author_email": author.get("email", ""), | |
| 41 | "date": author.get("date", ""), | |
| 42 | "message": commit.get("message", "").splitlines()[0], | |
| 43 | "additions": stats.get("additions", ""), | |
| 44 | "deletions": stats.get("deletions", ""), | |
| 45 | } | |
| 46 | ) | |
| 45 | 47 | |
| 46 | 48 | return rows |
applications/github/collectors/members.py +67 −42
| @@ -50,22 +50,31 @@ def fetch_repo_collaborators(org, cfg): | ||
| 50 | 50 | ) |
| 51 | 51 | except requests.HTTPError as e: |
| 52 | 52 | if e.response is not None and e.response.status_code == 403: |
| 53 | print(f" Skipping {repo_name}: collaborators endpoint returned 403", file=sys.stderr) | |
| 53 | print( | |
| 54 | f" Skipping {repo_name}: collaborators endpoint returned 403", | |
| 55 | file=sys.stderr, | |
| 56 | ) | |
| 54 | 57 | continue |
| 55 | 58 | raise |
| 56 | results.append({ | |
| 57 | "repo": repo_name, | |
| 58 | "visibility": repo["visibility"], | |
| 59 | "collaborators": collabs, | |
| 60 | }) | |
| 59 | results.append( | |
| 60 | { | |
| 61 | "repo": repo_name, | |
| 62 | "visibility": repo["visibility"], | |
| 63 | "collaborators": collabs, | |
| 64 | } | |
| 65 | ) | |
| 61 | 66 | return results |
| 62 | 67 | |
| 63 | 68 | |
| 64 | 69 | def member_roster(org, cfg): |
| 65 | members = paginate(f"https://api.github.com/orgs/{org}/members", cfg, {"role": "all"}) | |
| 70 | members = paginate( | |
| 71 | f"https://api.github.com/orgs/{org}/members", cfg, {"role": "all"} | |
| 72 | ) | |
| 66 | 73 | owners = { |
| 67 | 74 | m["login"] |
| 68 | for m in paginate(f"https://api.github.com/orgs/{org}/members", cfg, {"role": "owner"}) | |
| 75 | for m in paginate( | |
| 76 | f"https://api.github.com/orgs/{org}/members", cfg, {"role": "owner"} | |
| 77 | ) | |
| 69 | 78 | } |
| 70 | 79 | return [ |
| 71 | 80 | { |
| @@ -104,18 +113,22 @@ def outside_collaborators(org, cfg, repo_collabs): | ||
| 104 | 113 | """ |
| 105 | 114 | outside = { |
| 106 | 115 | m["login"] |
| 107 | for m in paginate(f"https://api.github.com/orgs/{org}/outside_collaborators", cfg) | |
| 116 | for m in paginate( | |
| 117 | f"https://api.github.com/orgs/{org}/outside_collaborators", cfg | |
| 118 | ) | |
| 108 | 119 | } |
| 109 | 120 | rows = [] |
| 110 | 121 | for entry in repo_collabs: |
| 111 | 122 | for c in entry["collaborators"]: |
| 112 | 123 | if c["login"] in outside: |
| 113 | rows.append({ | |
| 114 | "login": c["login"], | |
| 115 | "repo": entry["repo"], | |
| 116 | "permission": _permission_level(c.get("permissions", {})), | |
| 117 | "repo_visibility": entry["visibility"], | |
| 118 | }) | |
| 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 | ) | |
| 119 | 132 | return rows |
| 120 | 133 | |
| 121 | 134 | |
| @@ -128,12 +141,14 @@ def privileged_access(org, cfg, repo_collabs): | ||
| 128 | 141 | for entry in repo_collabs: |
| 129 | 142 | for c in entry["collaborators"]: |
| 130 | 143 | if c.get("permissions", {}).get("admin"): |
| 131 | rows.append({ | |
| 132 | "login": c["login"], | |
| 133 | "repo": entry["repo"], | |
| 134 | "permission": "admin", | |
| 135 | "repo_visibility": entry["visibility"], | |
| 136 | }) | |
| 144 | rows.append( | |
| 145 | { | |
| 146 | "login": c["login"], | |
| 147 | "repo": entry["repo"], | |
| 148 | "permission": "admin", | |
| 149 | "repo_visibility": entry["visibility"], | |
| 150 | } | |
| 151 | ) | |
| 137 | 152 | return rows |
| 138 | 153 | |
| 139 | 154 | |
| @@ -146,13 +161,15 @@ def pending_invitations(org, cfg): | ||
| 146 | 161 | if created: |
| 147 | 162 | dt = datetime.fromisoformat(created.replace("Z", "+00:00")) |
| 148 | 163 | age_days = (now - dt).days |
| 149 | rows.append({ | |
| 150 | "login": inv.get("login") or inv.get("email", "unknown"), | |
| 151 | "role": inv.get("role", ""), | |
| 152 | "invited_by": inv.get("inviter", {}).get("login", ""), | |
| 153 | "created_at": created, | |
| 154 | "age_days": age_days, | |
| 155 | }) | |
| 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 | ) | |
| 156 | 173 | return rows |
| 157 | 174 | |
| 158 | 175 | |
| @@ -160,16 +177,22 @@ def team_permissions(org, cfg): | ||
| 160 | 177 | rows = [] |
| 161 | 178 | for team in paginate(f"https://api.github.com/orgs/{org}/teams", cfg): |
| 162 | 179 | slug = team["slug"] |
| 163 | team_members = paginate(f"https://api.github.com/orgs/{org}/teams/{slug}/members", cfg) | |
| 164 | team_repos = paginate(f"https://api.github.com/orgs/{org}/teams/{slug}/repos", cfg) | |
| 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 | ) | |
| 165 | 186 | member_logins = ", ".join(m["login"] for m in team_members) or "(none)" |
| 166 | 187 | for repo in team_repos: |
| 167 | rows.append({ | |
| 168 | "team": team["name"], | |
| 169 | "repo": repo["name"], | |
| 170 | "permission": _permission_level(repo.get("permissions", {})), | |
| 171 | "members": member_logins, | |
| 172 | }) | |
| 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 | ) | |
| 173 | 196 | return rows |
| 174 | 197 | |
| 175 | 198 | |
| @@ -181,10 +204,12 @@ def permission_matrix(org, cfg, repo_collabs): | ||
| 181 | 204 | rows = [] |
| 182 | 205 | for entry in repo_collabs: |
| 183 | 206 | for c in entry["collaborators"]: |
| 184 | rows.append({ | |
| 185 | "repo": entry["repo"], | |
| 186 | "login": c["login"], | |
| 187 | "permission": _permission_level(c.get("permissions", {})), | |
| 188 | "visibility": entry["visibility"], | |
| 189 | }) | |
| 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 | ) | |
| 190 | 215 | return rows |
applications/github/reporters/csv_reporter.py +3 −3
| @@ -31,10 +31,10 @@ def write_summary(output_dir, org, sections): | ||
| 31 | 31 | """ |
| 32 | 32 | path = os.path.join(output_dir, "summary.txt") |
| 33 | 33 | lines = [ |
| 34 | f"GitHub Audit Package", | |
| 34 | "GitHub Audit Package", | |
| 35 | 35 | f"Org: {org}", |
| 36 | f"", | |
| 37 | f"Section Rows", | |
| 36 | "", | |
| 37 | "Section Rows", | |
| 38 | 38 | f"{'─' * 40}", |
| 39 | 39 | ] |
| 40 | 40 | for label, count in sections: |