Commit 7f54baf67e
Unsigned
Layout: unified · split
.gitignore +7
| @@ -1,3 +1,10 @@ | ||
| 1 | 1 | .venv |
| 2 | 2 | venv |
| 3 | 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 | |
| 2 | [PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) | |
| 3 | for all scripts within this folder. Note that you can likely reduce | |
| 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. | |
| 1 | > **NOTE**: The PAT used across all scripts needs the following minimum permissions: | |
| 2 | > - Repository: Actions (read), Contents (read), Metadata (read), Workflows (read) | |
| 3 | > - Organization: Administration (read), Members (read) | |
| 6 | 4 | |
| 7 | - Personal Access Token: | |
| 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 | |
| 5 | --- | |
| 15 | 6 | |
| 16 | # `github_admins.py` | |
| 7 | # `audit.py` — Unified GitHub Audit Tool | |
| 17 | 8 | |
| 18 | ``` bash | |
| 19 | python ./github_admins.py | |
| 20 | ``` | |
| 21 | ||
| 22 | ``` text | |
| 23 | Members of the organization 'your_organization': | |
| 9 | Runs all collectors against a GitHub organization and writes a timestamped | |
| 10 | audit package to disk. | |
| 24 | 11 | |
| 25 | Repositories in the organization 'your_organization': | |
| 26 | - demo-repository | |
| 12 | ## Setup | |
| 27 | 13 | |
| 28 | Collaborators for the repository 'demo-repository': | |
| 29 | - user1: admin | |
| 14 | ```bash | |
| 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 | |
| 37 | python ./github_audit_log.py | |
| 38 | ``` | |
| 25 | # Override org, set output directory | |
| 26 | python audit.py --org my-org --out ./output | |
| 39 | 27 | |
| 40 | ``` text | |
| 41 | TODO: Need to get an Enterprise subscription to test this script. | |
| 42 | ``` | |
| 28 | # Collect commits from a non-default branch | |
| 29 | python audit.py --branch develop | |
| 43 | 30 | |
| 44 | # `github_branch_protections.py` | |
| 45 | ||
| 46 | ``` bash | |
| 47 | python ./github_branch_protections.py | |
| 31 | # Include audit log (requires GitHub Enterprise) | |
| 32 | python audit.py --include-audit-log | |
| 48 | 33 | ``` |
| 49 | 34 | |
| 50 | ``` text | |
| 51 | Total branches in the repository 'demo-repository': 1 | |
| 35 | ## Output | |
| 52 | 36 | |
| 53 | Branch: main | |
| 54 | No protection settings | |
| 37 | Creates a directory: `<out>/github_audit_<org>_<YYYY-MM-DD>/` | |
| 55 | 38 | |
| 56 | Repository rulesets for 'demo-repository': | |
| 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'}] | |
| 58 | ``` | |
| 39 | | File | Contents | | |
| 40 | |---|---| | |
| 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. | |