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

Add unified GitHub audit tool

- Add audit.py CLI entrypoint (argparse, env var config)
- Add collectors/ package: members, branch_protections, commits, audit_log
- Add reporters/ package: csv_reporter
- Fetch repo collaborators once, share cache across three sections
- Add two_factor_disabled collector
- Remove legacy standalone scripts
- Update .gitignore: __pycache__, *.pyc, output/
- Add os/windows/ and databases/snowflake/ READMEs (planned)
 .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(-)

diff --git a/.gitignore b/.gitignore
index 840093e..9a21d23 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,10 @@
 .venv
 venv
 readme.html
+
+# Python
+__pycache__/
+*.py[cod]
+
+# Audit output
+applications/github/output/
diff --git a/applications/github/README.md b/applications/github/README.md
index 3ea076b..0a57854 100644
--- a/applications/github/README.md
+++ b/applications/github/README.md
@@ -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
-```
diff --git a/applications/github/audit.py b/applications/github/audit.py
new file mode 100644
index 0000000..5f6d87e
--- /dev/null
+++ b/applications/github/audit.py
@@ -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()
diff --git a/applications/github/collectors/__init__.py b/applications/github/collectors/__init__.py
new file mode 100644
index 0000000..3155c19
--- /dev/null
+++ b/applications/github/collectors/__init__.py
@@ -0,0 +1 @@
+"""Package init files."""
diff --git a/applications/github/collectors/api.py b/applications/github/collectors/api.py
new file mode 100644
index 0000000..c7fc695
--- /dev/null
+++ b/applications/github/collectors/api.py
@@ -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
diff --git a/applications/github/collectors/audit_log.py b/applications/github/collectors/audit_log.py
new file mode 100644
index 0000000..782f89b
--- /dev/null
+++ b/applications/github/collectors/audit_log.py
@@ -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
diff --git a/applications/github/collectors/branch_protections.py b/applications/github/collectors/branch_protections.py
new file mode 100644
index 0000000..fa21384
--- /dev/null
+++ b/applications/github/collectors/branch_protections.py
@@ -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
diff --git a/applications/github/collectors/commits.py b/applications/github/collectors/commits.py
new file mode 100644
index 0000000..cb03160
--- /dev/null
+++ b/applications/github/collectors/commits.py
@@ -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
diff --git a/applications/github/collectors/members.py b/applications/github/collectors/members.py
new file mode 100644
index 0000000..731983d
--- /dev/null
+++ b/applications/github/collectors/members.py
@@ -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
diff --git a/applications/github/config.py b/applications/github/config.py
new file mode 100644
index 0000000..2fb15f0
--- /dev/null
+++ b/applications/github/config.py
@@ -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,
+    }
diff --git a/applications/github/github_admins.py b/applications/github/github_admins.py
deleted file mode 100644
index b6db534..0000000
--- a/applications/github/github_admins.py
+++ /dev/null
@@ -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']}")
diff --git a/applications/github/github_audit_log.py b/applications/github/github_audit_log.py
deleted file mode 100644
index dd00535..0000000
--- a/applications/github/github_audit_log.py
+++ /dev/null
@@ -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}")
diff --git a/applications/github/github_branch_protections.py b/applications/github/github_branch_protections.py
deleted file mode 100644
index 705e498..0000000
--- a/applications/github/github_branch_protections.py
+++ /dev/null
@@ -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}")
diff --git a/applications/github/github_commits.py b/applications/github/github_commits.py
deleted file mode 100644
index 7581ff1..0000000
--- a/applications/github/github_commits.py
+++ /dev/null
@@ -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}")
diff --git a/applications/github/reporters/__init__.py b/applications/github/reporters/__init__.py
new file mode 100644
index 0000000..3155c19
--- /dev/null
+++ b/applications/github/reporters/__init__.py
@@ -0,0 +1 @@
+"""Package init files."""
diff --git a/applications/github/reporters/csv_reporter.py b/applications/github/reporters/csv_reporter.py
new file mode 100644
index 0000000..f7cbf6a
--- /dev/null
+++ b/applications/github/reporters/csv_reporter.py
@@ -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}")
diff --git a/databases/snowflake/README.md b/databases/snowflake/README.md
new file mode 100644
index 0000000..79505da
--- /dev/null
+++ b/databases/snowflake/README.md
@@ -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.
diff --git a/os/windows/README.md b/os/windows/README.md
new file mode 100644
index 0000000..ffd28d6
--- /dev/null
+++ b/os/windows/README.md
@@ -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.