Commit 7f54baf67e
Unsigned
Layout: unified · split
.gitignore +7
| @@ -1,3 +1,10 @@ | |||
| 1 | .venv | 1 | .venv |
| 2 | venv | 2 | venv |
| 3 | readme.html | 3 | readme.html |
| 4 | |||
| 5 | # Python | ||
| 6 | __pycache__/ | ||
| 7 | *.py[cod] | ||
| 8 | |||
| 9 | # Audit output | ||
| 10 | applications/github/output/ | ||
applications/github/README.md +36 −96
| @@ -1,113 +1,53 @@ | |||
| 1 | **NOTE**: I used the same | 1 | > **NOTE**: The PAT used across all scripts needs the following minimum permissions: |
| 2 | [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) | 2 | > - Repository: Actions (read), Contents (read), Metadata (read), Workflows (read) |
| 3 | for all scripts within this folder. Note that you can likely reduce | 3 | > - Organization: Administration (read), Members (read) |
| 4 | permissions for certain scripts - it's best practice to define a PAT for | ||
| 5 | a specific purpose and avoid using a single PAT with broad permissions. | ||
| 6 | 4 | ||
| 7 | - Personal Access Token: | 5 | --- |
| 8 | - \[x\] Repository Permissions | ||
| 9 | - \[x\] Actions: read-only | ||
| 10 | - \[x\] Contents: read-only | ||
| 11 | - \[x\] Metadata: read-only | ||
| 12 | - \[x\] Workflows: read-only | ||
| 13 | - \[x\] Organization Permissions | ||
| 14 | - \[x\] Administration: read-only | ||
| 15 | 6 | ||
| 16 | # `github_admins.py` | 7 | # `audit.py` — Unified GitHub Audit Tool |
| 17 | 8 | ||
| 18 | ``` bash | 9 | Runs all collectors against a GitHub organization and writes a timestamped |
| 19 | python ./github_admins.py | 10 | audit package to disk. |
| 20 | ``` | ||
| 21 | |||
| 22 | ``` text | ||
| 23 | Members of the organization 'your_organization': | ||
| 24 | 11 | ||
| 25 | Repositories in the organization 'your_organization': | 12 | ## Setup |
| 26 | - demo-repository | ||
| 27 | 13 | ||
| 28 | Collaborators for the repository 'demo-repository': | 14 | ```bash |
| 29 | - user1: admin | 15 | export GITHUB_TOKEN=your_token |
| 16 | export GITHUB_ORG=your_organization | ||
| 30 | ``` | 17 | ``` |
| 31 | 18 | ||
| 32 | # `github_audit_log.py` | 19 | ## Usage |
| 33 | 20 | ||
| 34 | **NOTE**: Requires an active GitHub Enterprise subscription. | 21 | ```bash |
| 22 | # Basic run — uses GITHUB_TOKEN and GITHUB_ORG from environment | ||
| 23 | python audit.py | ||
| 35 | 24 | ||
| 36 | ``` bash | 25 | # Override org, set output directory |
| 37 | python ./github_audit_log.py | 26 | python audit.py --org my-org --out ./output |
| 38 | ``` | ||
| 39 | 27 | ||
| 40 | ``` text | 28 | # Collect commits from a non-default branch |
| 41 | TODO: Need to get an Enterprise subscription to test this script. | 29 | python audit.py --branch develop |
| 42 | ``` | ||
| 43 | 30 | ||
| 44 | # `github_branch_protections.py` | 31 | # Include audit log (requires GitHub Enterprise) |
| 45 | 32 | python audit.py --include-audit-log | |
| 46 | ``` bash | ||
| 47 | python ./github_branch_protections.py | ||
| 48 | ``` | 33 | ``` |
| 49 | 34 | ||
| 50 | ``` text | 35 | ## Output |
| 51 | Total branches in the repository 'demo-repository': 1 | ||
| 52 | 36 | ||
| 53 | Branch: main | 37 | Creates a directory: `<out>/github_audit_<org>_<YYYY-MM-DD>/` |
| 54 | No protection settings | ||
| 55 | 38 | ||
| 56 | Repository rulesets for 'demo-repository': | 39 | | File | Contents | |
| 57 | [{'id': 2311373, 'name': 'default', 'target': 'branch', 'source_type': 'Repository', 'source': 'phryq/demo-repository', 'enforcement': 'active', 'node_id': 'RRS_lACqUmVwb3NpdG9yec40LV1PzgAjRM0', '_links': {'self': {'href': 'https://api.github.com/repos/phryq/demo-repository/rulesets/2311373'}, 'html': {'href': 'https://github.com/phryq/demo-repository/rules/2311373'}}, 'created_at': '2024-10-19T15:59:35.200-05:00', 'updated_at': '2024-10-19T15:59:35.200-05:00'}] | 40 | |---|---| |
| 58 | ``` | 41 | | `member_roster.csv` | All org members with role (owner vs member) | |
| 42 | | `two_factor_disabled.csv` | Org members without 2FA enabled | | ||
| 43 | | `outside_collaborators.csv` | Non-org members with direct repo access | | ||
| 44 | | `privileged_access.csv` | All users with admin permission on any repo | | ||
| 45 | | `pending_invitations.csv` | Invitations not yet accepted, with age in days | | ||
| 46 | | `team_permissions.csv` | Teams, their repos, permissions, and members | | ||
| 47 | | `permission_matrix.csv` | Full user/repo/permission cross-reference | | ||
| 48 | | `branch_protections.csv` | Branch protection settings across all repos | | ||
| 49 | | `commits.csv` | Commit history across all repos for the target branch | | ||
| 50 | | `audit_log.csv` | Org-level audit events (Enterprise only, opt-in) | | ||
| 51 | | `summary.txt` | Row counts per section | | ||
| 59 | 52 | ||
| 60 | # `github_commits.py` | ||
| 61 | 53 | ||
| 62 | ``` bash | ||
| 63 | python ./github_commits.py | ||
| 64 | ``` | ||
| 65 | |||
| 66 | ``` text | ||
| 67 | Total commits in the repository 'demo-repository' on branch 'main': 3 | ||
| 68 | |||
| 69 | Commit SHA: 13c488a2cdda08e4043f8ef36ced5fdd429e9718 | ||
| 70 | Author: Christian Cleberg <156287552+ccleberg@users.noreply.github.com> | ||
| 71 | Date: 2024-10-19T20:57:55Z | ||
| 72 | Message: Merge pull request #2 from phryq/1-test-issue | ||
| 73 | |||
| 74 | fixes | ||
| 75 | URL: https://github.com/phryq/demo-repository/commit/13c488a2cdda08e4043f8ef36ced5fdd429e9718 | ||
| 76 | Files changed: | ||
| 77 | - .gitignore (added) | ||
| 78 | Additions: 0, Deletions: 0, Changes: 0 | ||
| 79 | - README.md (removed) | ||
| 80 | Additions: 0, Deletions: 4, Changes: 4 | ||
| 81 | - README.org (added) | ||
| 82 | Additions: 7, Deletions: 0, Changes: 7 | ||
| 83 | |||
| 84 | Commit SHA: 6bfde238a2a34a93ce8ee02082eaf4ab3c189368 | ||
| 85 | Author: Christian Cleberg <hello@cmc.pub> | ||
| 86 | Date: 2024-10-19T20:56:50Z | ||
| 87 | Message: fixes | ||
| 88 | URL: https://github.com/phryq/demo-repository/commit/6bfde238a2a34a93ce8ee02082eaf4ab3c189368 | ||
| 89 | Files changed: | ||
| 90 | - .gitignore (added) | ||
| 91 | Additions: 0, Deletions: 0, Changes: 0 | ||
| 92 | - README.md (removed) | ||
| 93 | Additions: 0, Deletions: 4, Changes: 4 | ||
| 94 | - README.org (added) | ||
| 95 | Additions: 7, Deletions: 0, Changes: 7 | ||
| 96 | |||
| 97 | Commit SHA: be1ddf31e08fc790f54d68f8067b7b2f3805f999 | ||
| 98 | Author: Christian Cleberg <156287552+ccleberg@users.noreply.github.com> | ||
| 99 | Date: 2024-10-19T20:54:08Z | ||
| 100 | Message: Initial commit | ||
| 101 | URL: https://github.com/phryq/demo-repository/commit/be1ddf31e08fc790f54d68f8067b7b2f3805f999 | ||
| 102 | Files changed: | ||
| 103 | - .github/workflows/auto-assign.yml (added) | ||
| 104 | Additions: 19, Deletions: 0, Changes: 19 | ||
| 105 | - .github/workflows/proof-html.yml (added) | ||
| 106 | Additions: 11, Deletions: 0, Changes: 11 | ||
| 107 | - README.md (added) | ||
| 108 | Additions: 4, Deletions: 0, Changes: 4 | ||
| 109 | - index.html (added) | ||
| 110 | Additions: 1, Deletions: 0, Changes: 1 | ||
| 111 | - package.json (added) | ||
| 112 | Additions: 9, Deletions: 0, Changes: 9 | ||
| 113 | ``` | ||
applications/github/audit.py added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | """ | ||
| 2 | GitHub audit CLI. | ||
| 3 | |||
| 4 | Runs all collectors against a GitHub organization and writes a timestamped | ||
| 5 | audit package to an output directory. | ||
| 6 | |||
| 7 | Usage: | ||
| 8 | export GITHUB_TOKEN=your_token | ||
| 9 | export GITHUB_ORG=your_org | ||
| 10 | |||
| 11 | python audit.py | ||
| 12 | python audit.py --org my-org | ||
| 13 | python audit.py --org my-org --out ./output | ||
| 14 | python audit.py --org my-org --branch main --include-audit-log | ||
| 15 | |||
| 16 | Output: | ||
| 17 | <out>/github_audit_<org>_<date>/ | ||
| 18 | member_roster.csv | ||
| 19 | two_factor_disabled.csv | ||
| 20 | outside_collaborators.csv | ||
| 21 | privileged_access.csv | ||
| 22 | pending_invitations.csv | ||
| 23 | team_permissions.csv | ||
| 24 | permission_matrix.csv | ||
| 25 | branch_protections.csv | ||
| 26 | commits.csv | ||
| 27 | audit_log.csv (only with --include-audit-log) | ||
| 28 | summary.txt | ||
| 29 | """ | ||
| 30 | |||
| 31 | import argparse | ||
| 32 | import os | ||
| 33 | import sys | ||
| 34 | from datetime import date | ||
| 35 | |||
| 36 | import config | ||
| 37 | from collectors import members, branch_protections, commits, audit_log | ||
| 38 | from reporters import csv_reporter | ||
| 39 | |||
| 40 | |||
| 41 | def parse_args(): | ||
| 42 | parser = argparse.ArgumentParser( | ||
| 43 | description="Generate a GitHub audit package for an organization." | ||
| 44 | ) | ||
| 45 | parser.add_argument( | ||
| 46 | "--org", | ||
| 47 | help="GitHub organization name. Overrides GITHUB_ORG env var.", | ||
| 48 | ) | ||
| 49 | parser.add_argument( | ||
| 50 | "--out", | ||
| 51 | default="./output", | ||
| 52 | help="Directory to write the audit package into. Default: ./output", | ||
| 53 | ) | ||
| 54 | parser.add_argument( | ||
| 55 | "--branch", | ||
| 56 | default="main", | ||
| 57 | help="Branch to collect commits from. Default: main", | ||
| 58 | ) | ||
| 59 | parser.add_argument( | ||
| 60 | "--include-audit-log", | ||
| 61 | action="store_true", | ||
| 62 | help="Include audit log collection (requires GitHub Enterprise).", | ||
| 63 | ) | ||
| 64 | return parser.parse_args() | ||
| 65 | |||
| 66 | |||
| 67 | def run(): | ||
| 68 | args = parse_args() | ||
| 69 | cfg = config.load(org_override=args.org) | ||
| 70 | org = cfg["org"] | ||
| 71 | |||
| 72 | output_dir = os.path.join( | ||
| 73 | args.out, f"github_audit_{org}_{date.today().isoformat()}" | ||
| 74 | ) | ||
| 75 | |||
| 76 | print(f"GitHub Audit — {org}") | ||
| 77 | print(f"Output directory: {output_dir}") | ||
| 78 | print() | ||
| 79 | |||
| 80 | sections = [] | ||
| 81 | |||
| 82 | def collect(label, fn, filename, *fn_args): | ||
| 83 | print(f"Collecting: {label}...") | ||
| 84 | try: | ||
| 85 | rows = fn(*fn_args) | ||
| 86 | except Exception as e: | ||
| 87 | print(f" Error: {e}", file=sys.stderr) | ||
| 88 | rows = [] | ||
| 89 | csv_reporter.write(output_dir, filename, rows) | ||
| 90 | sections.append((label, len(rows))) | ||
| 91 | return rows | ||
| 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) | ||
| 95 | |||
| 96 | print("Fetching repo collaborators (shared cache)...") | ||
| 97 | try: | ||
| 98 | repo_collabs = members.fetch_repo_collaborators(org, cfg) | ||
| 99 | except Exception as e: | ||
| 100 | print(f" Error fetching collaborators: {e}", file=sys.stderr) | ||
| 101 | repo_collabs = [] | ||
| 102 | |||
| 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 | |||
| 111 | if args.include_audit_log: | ||
| 112 | collect("Audit log", audit_log.audit_log, "audit_log.csv", org, cfg) | ||
| 113 | |||
| 114 | print() | ||
| 115 | csv_reporter.write_summary(output_dir, org, sections) | ||
| 116 | print() | ||
| 117 | print("Done.") | ||
| 118 | |||
| 119 | |||
| 120 | if __name__ == "__main__": | ||
| 121 | run() | ||
applications/github/collectors/__init__.py added +1
| @@ -0,0 +1 @@ | |||
| 1 | """Package init files.""" | ||
applications/github/collectors/api.py added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | """Shared GitHub API helper.""" | ||
| 2 | |||
| 3 | import requests | ||
| 4 | |||
| 5 | |||
| 6 | def paginate(url, cfg, params=None): | ||
| 7 | """Fetch all pages from a GitHub API endpoint and return combined results.""" | ||
| 8 | results = [] | ||
| 9 | p = dict(params or {}) | ||
| 10 | p["per_page"] = 100 | ||
| 11 | page = 1 | ||
| 12 | |||
| 13 | while True: | ||
| 14 | p["page"] = page | ||
| 15 | resp = requests.get(url, headers=cfg["headers"], params=p, timeout=cfg["timeout"]) | ||
| 16 | resp.raise_for_status() | ||
| 17 | data = resp.json() | ||
| 18 | if not data: | ||
| 19 | break | ||
| 20 | results.extend(data) | ||
| 21 | if "next" not in resp.links: | ||
| 22 | break | ||
| 23 | page += 1 | ||
| 24 | |||
| 25 | return results | ||
applications/github/collectors/audit_log.py added +53
| @@ -0,0 +1,53 @@ | |||
| 1 | """ | ||
| 2 | Collect GitHub audit log events. | ||
| 3 | |||
| 4 | Requires GitHub Enterprise. Skips gracefully with a warning if not available. | ||
| 5 | """ | ||
| 6 | |||
| 7 | import sys | ||
| 8 | |||
| 9 | from .api import paginate | ||
| 10 | |||
| 11 | # Default event categories relevant to a security audit | ||
| 12 | DEFAULT_ACTIONS = [ | ||
| 13 | "org.add_member", | ||
| 14 | "org.remove_member", | ||
| 15 | "org.update_member", | ||
| 16 | "protected_branch", | ||
| 17 | "repo.access", | ||
| 18 | "repo.create", | ||
| 19 | "repo.destroy", | ||
| 20 | "team.add_member", | ||
| 21 | "team.remove_member", | ||
| 22 | ] | ||
| 23 | |||
| 24 | |||
| 25 | def audit_log(org, cfg, actions=None): | ||
| 26 | """ | ||
| 27 | Return audit log events filtered by action list. | ||
| 28 | Returns an empty list with a warning if the org is not on GitHub Enterprise. | ||
| 29 | """ | ||
| 30 | actions = actions or DEFAULT_ACTIONS | ||
| 31 | url = f"https://api.github.com/orgs/{org}/audit-log" | ||
| 32 | |||
| 33 | try: | ||
| 34 | events = paginate(url, cfg, {"action": ",".join(actions)}) | ||
| 35 | except Exception as e: | ||
| 36 | if "403" in str(e) or "404" in str(e): | ||
| 37 | print( | ||
| 38 | "Warning: audit log requires GitHub Enterprise -- skipping.", | ||
| 39 | file=sys.stderr, | ||
| 40 | ) | ||
| 41 | return [] | ||
| 42 | raise | ||
| 43 | |||
| 44 | rows = [] | ||
| 45 | for e in events: | ||
| 46 | rows.append({ | ||
| 47 | "action": e.get("action", ""), | ||
| 48 | "actor": e.get("actor", ""), | ||
| 49 | "repo": e.get("repo", ""), | ||
| 50 | "created_at": e.get("created_at", ""), | ||
| 51 | "org": e.get("org", ""), | ||
| 52 | }) | ||
| 53 | return rows | ||
applications/github/collectors/branch_protections.py added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | """ | ||
| 2 | Collect branch protection and ruleset data across all repos in an org. | ||
| 3 | """ | ||
| 4 | |||
| 5 | import sys | ||
| 6 | |||
| 7 | import requests | ||
| 8 | |||
| 9 | from .api import paginate | ||
| 10 | |||
| 11 | |||
| 12 | def branch_protections(org, cfg): | ||
| 13 | """ | ||
| 14 | For each repo, return protection settings per branch and any rulesets. | ||
| 15 | Branches with no protection are included with protected=False. | ||
| 16 | Repos that return 403 on the branches endpoint are skipped with a warning. | ||
| 17 | """ | ||
| 18 | repos = paginate(f"https://api.github.com/orgs/{org}/repos", cfg) | ||
| 19 | rows = [] | ||
| 20 | |||
| 21 | for repo in repos: | ||
| 22 | repo_name = repo["name"] | ||
| 23 | |||
| 24 | try: | ||
| 25 | branches = paginate( | ||
| 26 | f"https://api.github.com/repos/{org}/{repo_name}/branches", cfg | ||
| 27 | ) | ||
| 28 | except requests.HTTPError as e: | ||
| 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) | ||
| 31 | continue | ||
| 32 | raise | ||
| 33 | |||
| 34 | for branch in branches: | ||
| 35 | branch_name = branch["name"] | ||
| 36 | url = ( | ||
| 37 | f"https://api.github.com/repos/{org}/{repo_name}" | ||
| 38 | f"/branches/{branch_name}/protection" | ||
| 39 | ) | ||
| 40 | resp = requests.get(url, headers=cfg["headers"], timeout=cfg["timeout"]) | ||
| 41 | |||
| 42 | 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 | }) | ||
| 54 | continue | ||
| 55 | |||
| 56 | resp.raise_for_status() | ||
| 57 | p = resp.json() | ||
| 58 | reviews = p.get("required_pull_request_reviews", {}) | ||
| 59 | checks = p.get("required_status_checks", {}) | ||
| 60 | |||
| 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 | }) | ||
| 72 | |||
| 73 | return rows | ||
applications/github/collectors/commits.py added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | """ | ||
| 2 | Collect commit history for all repos in an org. | ||
| 3 | """ | ||
| 4 | |||
| 5 | from .api import paginate | ||
| 6 | |||
| 7 | |||
| 8 | def commits(org, cfg, branch="main"): | ||
| 9 | """ | ||
| 10 | Return commits across all repos. Each row includes repo, branch, sha, | ||
| 11 | author, date, message (first line), and change counts. | ||
| 12 | |||
| 13 | Skips repos where the branch doesn't exist. | ||
| 14 | """ | ||
| 15 | repos = paginate(f"https://api.github.com/orgs/{org}/repos", cfg) | ||
| 16 | rows = [] | ||
| 17 | |||
| 18 | for repo in repos: | ||
| 19 | repo_name = repo["name"] | ||
| 20 | try: | ||
| 21 | repo_commits = paginate( | ||
| 22 | f"https://api.github.com/repos/{org}/{repo_name}/commits", | ||
| 23 | cfg, | ||
| 24 | {"sha": branch}, | ||
| 25 | ) | ||
| 26 | except Exception: | ||
| 27 | # Branch doesn't exist in this repo or other API error -- skip | ||
| 28 | continue | ||
| 29 | |||
| 30 | for c in repo_commits: | ||
| 31 | commit = c.get("commit", {}) | ||
| 32 | author = commit.get("author", {}) | ||
| 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 | }) | ||
| 45 | |||
| 46 | return rows | ||
applications/github/collectors/members.py added +190
| @@ -0,0 +1,190 @@ | |||
| 1 | """ | ||
| 2 | Collect org membership, access, and permission data. | ||
| 3 | |||
| 4 | Covers: | ||
| 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 | |||
| 14 | import sys | ||
| 15 | from datetime import datetime, timezone | ||
| 16 | |||
| 17 | import requests | ||
| 18 | |||
| 19 | from .api import paginate | ||
| 20 | |||
| 21 | |||
| 22 | def _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 | |||
| 29 | def _repos(org, cfg): | ||
| 30 | return paginate(f"https://api.github.com/orgs/{org}/repos", cfg) | ||
| 31 | |||
| 32 | |||
| 33 | def 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(f" Skipping {repo_name}: collaborators endpoint returned 403", file=sys.stderr) | ||
| 54 | continue | ||
| 55 | raise | ||
| 56 | results.append({ | ||
| 57 | "repo": repo_name, | ||
| 58 | "visibility": repo["visibility"], | ||
| 59 | "collaborators": collabs, | ||
| 60 | }) | ||
| 61 | return results | ||
| 62 | |||
| 63 | |||
| 64 | def member_roster(org, cfg): | ||
| 65 | members = paginate(f"https://api.github.com/orgs/{org}/members", cfg, {"role": "all"}) | ||
| 66 | owners = { | ||
| 67 | m["login"] | ||
| 68 | for m in paginate(f"https://api.github.com/orgs/{org}/members", cfg, {"role": "owner"}) | ||
| 69 | } | ||
| 70 | return [ | ||
| 71 | { | ||
| 72 | "login": m["login"], | ||
| 73 | "org_role": "owner" if m["login"] in owners else "member", | ||
| 74 | "profile_url": m["html_url"], | ||
| 75 | } | ||
| 76 | for m in members | ||
| 77 | ] | ||
| 78 | |||
| 79 | |||
| 80 | def two_factor_disabled(org, cfg): | ||
| 81 | """ | ||
| 82 | List org members who do not have 2FA enabled. | ||
| 83 | Requires the token to have read:org scope. | ||
| 84 | Note: GitHub only exposes this to org owners. | ||
| 85 | """ | ||
| 86 | members = paginate( | ||
| 87 | f"https://api.github.com/orgs/{org}/members", | ||
| 88 | cfg, | ||
| 89 | {"filter": "2fa_disabled"}, | ||
| 90 | ) | ||
| 91 | return [ | ||
| 92 | { | ||
| 93 | "login": m["login"], | ||
| 94 | "profile_url": m["html_url"], | ||
| 95 | } | ||
| 96 | for m in members | ||
| 97 | ] | ||
| 98 | |||
| 99 | |||
| 100 | def outside_collaborators(org, cfg, repo_collabs): | ||
| 101 | """ | ||
| 102 | Non-org members with direct repo access. | ||
| 103 | Accepts pre-fetched repo_collabs from fetch_repo_collaborators(). | ||
| 104 | """ | ||
| 105 | outside = { | ||
| 106 | m["login"] | ||
| 107 | for m in paginate(f"https://api.github.com/orgs/{org}/outside_collaborators", cfg) | ||
| 108 | } | ||
| 109 | rows = [] | ||
| 110 | for entry in repo_collabs: | ||
| 111 | for c in entry["collaborators"]: | ||
| 112 | 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 | }) | ||
| 119 | return rows | ||
| 120 | |||
| 121 | |||
| 122 | def privileged_access(org, cfg, repo_collabs): | ||
| 123 | """ | ||
| 124 | All users with admin permission on any repo. | ||
| 125 | Accepts pre-fetched repo_collabs from fetch_repo_collaborators(). | ||
| 126 | """ | ||
| 127 | rows = [] | ||
| 128 | for entry in repo_collabs: | ||
| 129 | for c in entry["collaborators"]: | ||
| 130 | 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 | }) | ||
| 137 | return rows | ||
| 138 | |||
| 139 | |||
| 140 | def pending_invitations(org, cfg): | ||
| 141 | now = datetime.now(timezone.utc) | ||
| 142 | rows = [] | ||
| 143 | for inv in paginate(f"https://api.github.com/orgs/{org}/invitations", cfg): | ||
| 144 | created = inv.get("created_at", "") | ||
| 145 | age_days = None | ||
| 146 | if created: | ||
| 147 | dt = datetime.fromisoformat(created.replace("Z", "+00:00")) | ||
| 148 | 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 | }) | ||
| 156 | return rows | ||
| 157 | |||
| 158 | |||
| 159 | def team_permissions(org, cfg): | ||
| 160 | rows = [] | ||
| 161 | for team in paginate(f"https://api.github.com/orgs/{org}/teams", cfg): | ||
| 162 | 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) | ||
| 165 | member_logins = ", ".join(m["login"] for m in team_members) or "(none)" | ||
| 166 | 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 | }) | ||
| 173 | return rows | ||
| 174 | |||
| 175 | |||
| 176 | def permission_matrix(org, cfg, repo_collabs): | ||
| 177 | """ | ||
| 178 | Full per-repo/per-user permission cross-reference. | ||
| 179 | Accepts pre-fetched repo_collabs from fetch_repo_collaborators(). | ||
| 180 | """ | ||
| 181 | rows = [] | ||
| 182 | for entry in repo_collabs: | ||
| 183 | 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 | }) | ||
| 190 | return rows | ||
applications/github/config.py added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | """ | ||
| 2 | Configuration loader for the GitHub audit tool. | ||
| 3 | |||
| 4 | Reads GITHUB_TOKEN and GITHUB_ORG from environment variables. | ||
| 5 | |||
| 6 | Usage: | ||
| 7 | export GITHUB_TOKEN=your_token | ||
| 8 | export GITHUB_ORG=your_organization | ||
| 9 | """ | ||
| 10 | |||
| 11 | import os | ||
| 12 | import sys | ||
| 13 | |||
| 14 | |||
| 15 | def load(org_override=None): | ||
| 16 | """ | ||
| 17 | Return a config dict. Exits with an error if required values are missing. | ||
| 18 | """ | ||
| 19 | token = os.environ.get("GITHUB_TOKEN", "").strip() | ||
| 20 | org = org_override or os.environ.get("GITHUB_ORG", "").strip() | ||
| 21 | |||
| 22 | missing = [] | ||
| 23 | if not token: | ||
| 24 | missing.append("GITHUB_TOKEN") | ||
| 25 | if not org: | ||
| 26 | missing.append("GITHUB_ORG (or pass --org)") | ||
| 27 | |||
| 28 | if missing: | ||
| 29 | print(f"Error: missing required values: {', '.join(missing)}", file=sys.stderr) | ||
| 30 | sys.exit(1) | ||
| 31 | |||
| 32 | return { | ||
| 33 | "token": token, | ||
| 34 | "org": org, | ||
| 35 | "headers": { | ||
| 36 | "Authorization": f"token {token}", | ||
| 37 | "Accept": "application/vnd.github.v3+json", | ||
| 38 | }, | ||
| 39 | "timeout": 30, | ||
| 40 | } | ||
applications/github/github_admins.py deleted −83
| @@ -1,83 +0,0 @@ | |||
| 1 | """ | ||
| 2 | Gather all members of a GitHub organization, all repos within that organization, | ||
| 3 | and list each user's permission per repo. | ||
| 4 | """ | ||
| 5 | |||
| 6 | import requests | ||
| 7 | |||
| 8 | GITHUB_TOKEN = "your_personal_access_token" | ||
| 9 | ORGANIZATION = "your_organization" | ||
| 10 | TIMEOUT = 30 | ||
| 11 | |||
| 12 | # Headers for authentication | ||
| 13 | headers = { | ||
| 14 | "Authorization": f"token {GITHUB_TOKEN}", | ||
| 15 | "Accept": "application/vnd.github.v3+json", | ||
| 16 | } | ||
| 17 | |||
| 18 | |||
| 19 | def get_org_members(org): | ||
| 20 | """ | ||
| 21 | Get members of an organization | ||
| 22 | """ | ||
| 23 | url = f"https://api.github.com/orgs/{org}/members" | ||
| 24 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 25 | response.raise_for_status() | ||
| 26 | return response.json() | ||
| 27 | |||
| 28 | |||
| 29 | def get_org_repos(org): | ||
| 30 | """ | ||
| 31 | Get repositories of an organization | ||
| 32 | """ | ||
| 33 | url = f"https://api.github.com/orgs/{org}/repos" | ||
| 34 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 35 | response.raise_for_status() | ||
| 36 | return response.json() | ||
| 37 | |||
| 38 | |||
| 39 | def get_repo_collaborators(org, repo): | ||
| 40 | """ | ||
| 41 | Get collaborators of a repository with their permissions | ||
| 42 | """ | ||
| 43 | url = f"https://api.github.com/repos/{org}/{repo}/collaborators" | ||
| 44 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 45 | response.raise_for_status() | ||
| 46 | return response.json() | ||
| 47 | |||
| 48 | |||
| 49 | def get_user_permissions(org, repo, user): | ||
| 50 | """ | ||
| 51 | Get a user's permissions for a repository | ||
| 52 | """ | ||
| 53 | url = f"https://api.github.com/repos/{org}/{repo}/collaborators/{user}/permission" | ||
| 54 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 55 | response.raise_for_status() | ||
| 56 | return response.json() | ||
| 57 | |||
| 58 | |||
| 59 | # Main script | ||
| 60 | if __name__ == "__main__": | ||
| 61 | # Get organization members | ||
| 62 | members = get_org_members(ORGANIZATION) | ||
| 63 | print(f"Members of the organization '{ORGANIZATION}':") | ||
| 64 | for member in members: | ||
| 65 | print(f"- {member['login']}") | ||
| 66 | |||
| 67 | # Get organization repositories | ||
| 68 | repositories = get_org_repos(ORGANIZATION) | ||
| 69 | print(f"\nRepositories in the organization '{ORGANIZATION}':") | ||
| 70 | for repository in repositories: | ||
| 71 | print(f"- {repository['name']}") | ||
| 72 | |||
| 73 | # Get collaborators for each repository and their permissions | ||
| 74 | for repository in repositories: | ||
| 75 | repository_name = repository["name"] | ||
| 76 | collaborators = get_repo_collaborators(ORGANIZATION, repository_name) | ||
| 77 | print(f"\nCollaborators for the repository '{repository_name}':") | ||
| 78 | for collaborator in collaborators: | ||
| 79 | user_login = collaborator["login"] | ||
| 80 | permissions = get_user_permissions( | ||
| 81 | ORGANIZATION, repository_name, user_login | ||
| 82 | ) | ||
| 83 | print(f"- {user_login}: {permissions['permission']}") | ||
applications/github/github_audit_log.py deleted −65
| @@ -1,65 +0,0 @@ | |||
| 1 | """ | ||
| 2 | Extract a specific list of events from the GitHub Audit Log API. | ||
| 3 | |||
| 4 | NOTE: REQUIRES A GITHUB ENTERPRISE SUBSCRIPTION TO ACCESS THE API. | ||
| 5 | """ | ||
| 6 | |||
| 7 | import requests | ||
| 8 | |||
| 9 | GITHUB_TOKEN = "your_personal_access_token" | ||
| 10 | ORGANIZATION = "your_organization" | ||
| 11 | TIMEOUT = 30 | ||
| 12 | |||
| 13 | # Headers for authentication | ||
| 14 | headers = { | ||
| 15 | "Authorization": f"token {GITHUB_TOKEN}", | ||
| 16 | "Accept": "application/vnd.github.v3+json", | ||
| 17 | } | ||
| 18 | |||
| 19 | |||
| 20 | def get_audit_log_events(org, actions): | ||
| 21 | """ | ||
| 22 | Get audit log events for specific actions | ||
| 23 | """ | ||
| 24 | events = [] | ||
| 25 | page = 1 | ||
| 26 | while True: | ||
| 27 | url = ( | ||
| 28 | f"https://api.github.com/orgs/{org}/audit-log?page={page}&per_page=100" | ||
| 29 | f"&action={','.join(actions)}" | ||
| 30 | ) | ||
| 31 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 32 | response.raise_for_status() | ||
| 33 | page_events = response.json() | ||
| 34 | if not page_events: | ||
| 35 | break | ||
| 36 | events.extend(page_events) | ||
| 37 | page += 1 | ||
| 38 | return events | ||
| 39 | |||
| 40 | |||
| 41 | if __name__ == "__main__": | ||
| 42 | try: | ||
| 43 | # Define the actions to filter | ||
| 44 | action_filters = [ | ||
| 45 | "protected_branch", | ||
| 46 | "repository_branch_protection_evaluation", | ||
| 47 | "repository_ruleset", | ||
| 48 | ] | ||
| 49 | |||
| 50 | # Get audit log events for the specified actions | ||
| 51 | audit_log_events = get_audit_log_events(ORGANIZATION, action_filters) | ||
| 52 | print(f"Total audit log events for specified actions: {len(audit_log_events)}") | ||
| 53 | |||
| 54 | # Print detailed information for each event | ||
| 55 | for event in audit_log_events: | ||
| 56 | print(f"\nEvent ID: {event['@id']}") | ||
| 57 | print(f"Action: {event['action']}") | ||
| 58 | print(f"Actor: {event['actor']}") | ||
| 59 | print(f"Repository: {event.get('repo', 'N/A')}") | ||
| 60 | print(f"Created At: {event['created_at']}") | ||
| 61 | print(f"Details: {event}") | ||
| 62 | except requests.exceptions.Timeout: | ||
| 63 | print("The request timed out") | ||
| 64 | except requests.exceptions.RequestException as e: | ||
| 65 | print(f"An error occurred: {e}") | ||
applications/github/github_branch_protections.py deleted −84
| @@ -1,84 +0,0 @@ | |||
| 1 | """ | ||
| 2 | Gathers branch protection rules for a repository. | ||
| 3 | """ | ||
| 4 | |||
| 5 | import requests | ||
| 6 | |||
| 7 | GITHUB_TOKEN = "your_personal_access_token" | ||
| 8 | ORGANIZATION = "your_organization" | ||
| 9 | REPOSITORY = "your_repository" | ||
| 10 | TIMEOUT = 30 | ||
| 11 | |||
| 12 | headers = { | ||
| 13 | "Authorization": f"token {GITHUB_TOKEN}", | ||
| 14 | "Accept": "application/vnd.github.v3+json", | ||
| 15 | } | ||
| 16 | |||
| 17 | |||
| 18 | def get_all_branches(org, repo): | ||
| 19 | """ | ||
| 20 | Get all branches in a repository | ||
| 21 | """ | ||
| 22 | all_branches = [] | ||
| 23 | page = 1 | ||
| 24 | while True: | ||
| 25 | url = f"https://api.github.com/repos/{org}/{repo}/branches?page={page}&per_page=100" | ||
| 26 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 27 | response.raise_for_status() | ||
| 28 | page_branches = response.json() | ||
| 29 | if not page_branches: | ||
| 30 | break | ||
| 31 | all_branches.extend(page_branches) | ||
| 32 | page += 1 | ||
| 33 | return all_branches | ||
| 34 | |||
| 35 | |||
| 36 | def get_branch_protection(org, repo, repo_branch): | ||
| 37 | """ | ||
| 38 | Get branch protection settings | ||
| 39 | """ | ||
| 40 | url = f"https://api.github.com/repos/{org}/{repo}/branches/{repo_branch}/protection" | ||
| 41 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 42 | if response.status_code == 404: | ||
| 43 | return None # No protection settings for this branch | ||
| 44 | response.raise_for_status() | ||
| 45 | return response.json() | ||
| 46 | |||
| 47 | |||
| 48 | def get_repository_rulesets(org, repo): | ||
| 49 | """ | ||
| 50 | Get repository rulesets | ||
| 51 | """ | ||
| 52 | url = f"https://api.github.com/repos/{org}/{repo}/rulesets" | ||
| 53 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 54 | response.raise_for_status() | ||
| 55 | return response.json() | ||
| 56 | |||
| 57 | |||
| 58 | if __name__ == "__main__": | ||
| 59 | try: | ||
| 60 | # Get all branches in the repository | ||
| 61 | branches = get_all_branches(ORGANIZATION, REPOSITORY) | ||
| 62 | print(f"Total branches in the repository '{REPOSITORY}': {len(branches)}") | ||
| 63 | |||
| 64 | # Get protection settings for each branch | ||
| 65 | for branch in branches: | ||
| 66 | branch_name = branch["name"] | ||
| 67 | protection_settings = get_branch_protection( | ||
| 68 | ORGANIZATION, REPOSITORY, branch_name | ||
| 69 | ) | ||
| 70 | print(f"\nBranch: {branch_name}") | ||
| 71 | if protection_settings: | ||
| 72 | print(f"Protection settings: {protection_settings}") | ||
| 73 | else: | ||
| 74 | print("No protection settings") | ||
| 75 | |||
| 76 | # Get repository rulesets | ||
| 77 | rulesets = get_repository_rulesets(ORGANIZATION, REPOSITORY) | ||
| 78 | print(f"\nRepository rulesets for '{REPOSITORY}':") | ||
| 79 | print(rulesets) | ||
| 80 | |||
| 81 | except requests.exceptions.Timeout: | ||
| 82 | print("The request timed out") | ||
| 83 | except requests.exceptions.RequestException as e: | ||
| 84 | print(f"An error occurred: {e}") | ||
applications/github/github_commits.py deleted −85
| @@ -1,85 +0,0 @@ | |||
| 1 | """ | ||
| 2 | Gather all commits from a specific branch of a repository in a GitHub organization. | ||
| 3 | """ | ||
| 4 | |||
| 5 | import requests | ||
| 6 | |||
| 7 | GITHUB_TOKEN = "your_personal_access_token" | ||
| 8 | ORGANIZATION = "your_organization" | ||
| 9 | REPOSITORY = "your_repository" | ||
| 10 | BRANCH = "your_branch" | ||
| 11 | |||
| 12 | # Headers for authentication | ||
| 13 | headers = { | ||
| 14 | "Authorization": f"token {GITHUB_TOKEN}", | ||
| 15 | "Accept": "application/vnd.github.v3+json", | ||
| 16 | } | ||
| 17 | |||
| 18 | # Define a timeout value (in seconds) | ||
| 19 | TIMEOUT = 10 | ||
| 20 | |||
| 21 | |||
| 22 | def get_commit_log(org, repo, branch): | ||
| 23 | """ | ||
| 24 | Get the full commit log for a repository branch | ||
| 25 | """ | ||
| 26 | commits = [] | ||
| 27 | page = 1 | ||
| 28 | while True: | ||
| 29 | url = ( | ||
| 30 | f"https://api.github.com/repos/{org}/{repo}/commits?sha={branch}" | ||
| 31 | f"&page={page}&per_page=100" | ||
| 32 | ) | ||
| 33 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 34 | response.raise_for_status() | ||
| 35 | page_commits = response.json() | ||
| 36 | if not page_commits: | ||
| 37 | break | ||
| 38 | commits.extend(page_commits) | ||
| 39 | page += 1 | ||
| 40 | return commits | ||
| 41 | |||
| 42 | |||
| 43 | def get_commit_details(org, repo, sha): | ||
| 44 | """ | ||
| 45 | Get detailed information for a specific commit | ||
| 46 | """ | ||
| 47 | url = f"https://api.github.com/repos/{org}/{repo}/commits/{sha}" | ||
| 48 | response = requests.get(url, headers=headers, timeout=TIMEOUT) | ||
| 49 | response.raise_for_status() | ||
| 50 | return response.json() | ||
| 51 | |||
| 52 | |||
| 53 | if __name__ == "__main__": | ||
| 54 | try: | ||
| 55 | # Get the full commit log for the specified branch | ||
| 56 | commit_log = get_commit_log(ORGANIZATION, REPOSITORY, BRANCH) | ||
| 57 | print( | ||
| 58 | f"Total commits in the repository '{REPOSITORY}' on branch " | ||
| 59 | f"'{BRANCH}': {len(commit_log)}" | ||
| 60 | ) | ||
| 61 | |||
| 62 | # Get detailed information for each commit | ||
| 63 | for commit in commit_log: | ||
| 64 | sha_hash = commit["sha"] | ||
| 65 | commit_details = get_commit_details(ORGANIZATION, REPOSITORY, sha_hash) | ||
| 66 | print(f"\nCommit SHA: {commit_details['sha']}") | ||
| 67 | print( | ||
| 68 | f"Author: {commit_details['commit']['author']['name']} " | ||
| 69 | f"<{commit_details['commit']['author']['email']}>" | ||
| 70 | ) | ||
| 71 | print(f"Date: {commit_details['commit']['author']['date']}") | ||
| 72 | print(f"Message: {commit_details['commit']['message']}") | ||
| 73 | print(f"URL: {commit_details['html_url']}") | ||
| 74 | print("Files changed:") | ||
| 75 | for file in commit_details["files"]: | ||
| 76 | print(f" - {file['filename']} ({file['status']})") | ||
| 77 | print( | ||
| 78 | f" Additions: {file['additions']}, " | ||
| 79 | f"Deletions: {file['deletions']}, " | ||
| 80 | f"Changes: {file['changes']}" | ||
| 81 | ) | ||
| 82 | except requests.exceptions.Timeout: | ||
| 83 | print("The request timed out") | ||
| 84 | except requests.exceptions.RequestException as e: | ||
| 85 | print(f"An error occurred: {e}") | ||
applications/github/reporters/__init__.py added +1
| @@ -0,0 +1 @@ | |||
| 1 | """Package init files.""" | ||
applications/github/reporters/csv_reporter.py added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | """CSV reporter: writes one CSV file per data section into an output directory.""" | ||
| 2 | |||
| 3 | import csv | ||
| 4 | import os | ||
| 5 | |||
| 6 | |||
| 7 | def write(output_dir, filename, rows): | ||
| 8 | """ | ||
| 9 | Write a list of dicts to a CSV file in output_dir. | ||
| 10 | Skips writing if rows is empty, but logs the skip. | ||
| 11 | """ | ||
| 12 | if not rows: | ||
| 13 | print(f" {filename}: no data, skipping") | ||
| 14 | return | ||
| 15 | |||
| 16 | os.makedirs(output_dir, exist_ok=True) | ||
| 17 | path = os.path.join(output_dir, filename) | ||
| 18 | |||
| 19 | with open(path, "w", newline="", encoding="utf-8") as f: | ||
| 20 | writer = csv.DictWriter(f, fieldnames=rows[0].keys()) | ||
| 21 | writer.writeheader() | ||
| 22 | writer.writerows(rows) | ||
| 23 | |||
| 24 | print(f" {filename}: {len(rows)} rows -> {path}") | ||
| 25 | |||
| 26 | |||
| 27 | def write_summary(output_dir, org, sections): | ||
| 28 | """ | ||
| 29 | Write a plain-text summary file listing section names and row counts. | ||
| 30 | sections: list of (label, row_count) tuples | ||
| 31 | """ | ||
| 32 | path = os.path.join(output_dir, "summary.txt") | ||
| 33 | lines = [ | ||
| 34 | f"GitHub Audit Package", | ||
| 35 | f"Org: {org}", | ||
| 36 | f"", | ||
| 37 | f"Section Rows", | ||
| 38 | f"{'─' * 40}", | ||
| 39 | ] | ||
| 40 | for label, count in sections: | ||
| 41 | lines.append(f"{label:<35}{count}") | ||
| 42 | |||
| 43 | with open(path, "w", encoding="utf-8") as f: | ||
| 44 | f.write("\n".join(lines) + "\n") | ||
| 45 | |||
| 46 | print(f" summary.txt -> {path}") | ||
databases/snowflake/README.md added +30
| @@ -0,0 +1,30 @@ | |||
| 1 | # databases/snowflake | ||
| 2 | |||
| 3 | > Planned SQL scripts for Snowflake security audits. Mirrors the style of `databases/postgres/` | ||
| 4 | > and `databases/mysql/`. All queries target `SNOWFLAKE.ACCOUNT_USAGE` views, which require | ||
| 5 | > the ACCOUNTADMIN role or a role granted the SNOWFLAKE database privilege. | ||
| 6 | |||
| 7 | ## Planned Scripts | ||
| 8 | |||
| 9 | ### `admins.sql` | ||
| 10 | List users and roles holding `ACCOUNTADMIN`, `SECURITYADMIN`, or `SYSADMIN` via | ||
| 11 | `SNOWFLAKE.ACCOUNT_USAGE.GRANTS_TO_ROLES` and `GRANTS_TO_USERS`. | ||
| 12 | |||
| 13 | ### `passwords.sql` | ||
| 14 | Read account-level password policy parameters from `SNOWFLAKE.ACCOUNT_USAGE.ACCOUNT_PARAMETERS` | ||
| 15 | (min length, max age, lockout attempts, MFA enforcement). | ||
| 16 | |||
| 17 | ### `users.sql` | ||
| 18 | List all users from `SNOWFLAKE.ACCOUNT_USAGE.USERS` with `last_success_login`, `disabled`, | ||
| 19 | `must_change_password`, and `has_password` flags. | ||
| 20 | |||
| 21 | ### `network_policies.sql` | ||
| 22 | List all network policies and their assignments. Flag users with no network policy attached. | ||
| 23 | |||
| 24 | ### `stale_users.sql` | ||
| 25 | Filter `SNOWFLAKE.ACCOUNT_USAGE.USERS` for accounts inactive for 90+ days or that have | ||
| 26 | never logged in. | ||
| 27 | |||
| 28 | ### `service_accounts.sql` | ||
| 29 | Identify likely service accounts: no email set and `has_rsa_public_key = TRUE`. | ||
| 30 | Join against role grants to show what access each holds. | ||
os/windows/README.md added +36
| @@ -0,0 +1,36 @@ | |||
| 1 | # os/windows | ||
| 2 | |||
| 3 | > Planned PowerShell scripts for Windows security audits. Mirrors the structure of `os/linux/`. | ||
| 4 | > | ||
| 5 | > Open architectural decision: scripts can target local accounts only (`Get-LocalUser`), | ||
| 6 | > Active Directory (`Get-ADUser`), or both. This affects cmdlet choices across most scripts | ||
| 7 | > below and should be settled before implementation. | ||
| 8 | |||
| 9 | ## Planned Scripts | ||
| 10 | |||
| 11 | ### `local_admins.ps1` | ||
| 12 | List members of the local Administrators group via `Get-LocalGroupMember`. | ||
| 13 | |||
| 14 | ### `passwords.ps1` | ||
| 15 | Dump local password policy via `net accounts`. If domain-joined, also pull | ||
| 16 | `Get-ADDefaultDomainPasswordPolicy` (min length, max age, lockout threshold, history). | ||
| 17 | |||
| 18 | ### `audit_policy.ps1` | ||
| 19 | Read the Windows audit policy via `auditpol /get /category:*`. Flag whether logon, | ||
| 20 | account management, and privilege use events are being logged. | ||
| 21 | |||
| 22 | ### `rdp_settings.ps1` | ||
| 23 | Check if RDP is enabled, whether NLA is required, and which users/groups hold | ||
| 24 | "Allow log on through Remote Desktop Services" rights. | ||
| 25 | |||
| 26 | ### `inactive_users.ps1` | ||
| 27 | List local user accounts with last logon date. Flag accounts inactive past a | ||
| 28 | configurable threshold (e.g., 90 days). | ||
| 29 | |||
| 30 | ### `ad_admins.ps1` | ||
| 31 | If domain-joined: list members of Domain Admins, Enterprise Admins, and Schema Admins. | ||
| 32 | AD equivalent of `../../../applications/github/github_admins.py`. | ||
| 33 | |||
| 34 | ### `scheduled_tasks.ps1` | ||
| 35 | List scheduled tasks running as SYSTEM or with stored credentials. | ||
| 36 | Windows analog of a cron audit. | ||