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