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