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
9f0295c988cfdd39bcd5c1716d2f047adc585048
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-07-29T16:43:07Z
README.org | 2 +- applications/github/README.md | 10 +- applications/github/audit.py | 30 +++- applications/github/collectors/deploy_keys.py | 36 +++++ applications/github/collectors/org_settings.py | 40 +++++ applications/github/collectors/security_alerts.py | 63 ++++++++ applications/github/collectors/webhooks.py | 52 +++++++ tui/github_runner.py | 30 +++- tui/tests/test_github_collectors.py | 172 ++++++++++++++++++++++ 9 files changed, 431 insertions(+), 4 deletions(-) @@ -18,7 +18,7 @@ connection details, choose which checks to run, and watch live progress. | Directory | Description | |----------------------+------------------------------------------------------------------------------| | =applications/aws/= | AWS IAM users, password policy, and S3 bucket analysis | -| =applications/github/= | GitHub admin enumeration, audit log, branch protections, and commit analysis | +| =applications/github/= | GitHub admin enumeration, org security settings, webhooks, deploy keys, secret-scanning/Dependabot alerts, audit log, branch protections, commits | | =applications/gitlab/= | GitLab group/project members, branch protections, approvals, pipelines, audit events | | =databases/mongo/= | MongoDB admin enumeration | | =databases/mysql/= | MySQL admin and password queries | @@ -1,6 +1,9 @@ > **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) +> - Organization: Administration (read), Members (read), Webhooks (read) +> - Secret scanning and Dependabot alerts require GitHub Advanced Security and +> the corresponding read permissions; they are skipped with a warning if +> unavailable. > - Audit log collection also requires GitHub Enterprise Cloud. Classic PATs need > `read:audit_log`; fine-grained tokens need Organization Administration (read). @@ -46,6 +49,11 @@ Creates a directory: `<out>/github_audit_<org>_<YYYY-MM-DD>/` | `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 | +| `org_security.csv` | Org security settings (2FA requirement, default permission, repo creation, secret scanning defaults) | +| `webhooks.csv` | Org and per-repo webhooks, flagging plain-HTTP delivery and disabled SSL verification | +| `deploy_keys.csv` | Deploy keys across all repos (read-only vs read-write, last used) | +| `secret_scanning.csv` | Open secret-scanning alerts (Advanced Security) | +| `dependabot_alerts.csv` | Open Dependabot alerts with severity (Advanced Security) | | `audit_log.csv` | Branch protection and repository ruleset audit-log changes from the last 180 days (Enterprise Cloud only) | | `summary.txt` | Row counts per section | @@ -34,7 +34,16 @@ import sys from datetime import date import config -from collectors import audit_log, branch_protections, commits, members +from collectors import ( + audit_log, + branch_protections, + commits, + deploy_keys, + members, + org_settings, + security_alerts, + webhooks, +) from reporters import csv_reporter @@ -144,6 +153,25 @@ def run(): cfg, ) collect("Commits", commits.commits, "commits.csv", org, cfg, args.branch) + collect( + "Org security settings", org_settings.org_security, "org_security.csv", org, cfg + ) + collect("Webhooks", webhooks.webhooks, "webhooks.csv", org, cfg) + collect("Deploy keys", deploy_keys.deploy_keys, "deploy_keys.csv", org, cfg) + collect( + "Secret scanning alerts", + security_alerts.secret_scanning, + "secret_scanning.csv", + org, + cfg, + ) + collect( + "Dependabot alerts", + security_alerts.dependabot_alerts, + "dependabot_alerts.csv", + org, + cfg, + ) collect( "Audit log branch/ruleset changes", audit_log.audit_log, new file mode 100644 @@ -0,0 +1,36 @@ +"""Collect deploy keys across all repositories in an org.""" + +import sys + +import requests + +from .api import paginate + + +def deploy_keys(org, cfg): + rows = [] + for repo in paginate(f"https://api.github.com/orgs/{org}/repos", cfg): + name = repo["name"] + try: + keys = paginate(f"https://api.github.com/repos/{org}/{name}/keys", cfg) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code in (403, 404): + print( + f" Skipping {name}: keys endpoint returned " + f"{e.response.status_code}", + file=sys.stderr, + ) + continue + raise + for k in keys: + rows.append( + { + "repo": name, + "title": k.get("title", ""), + "read_only": k.get("read_only"), + "created_at": k.get("created_at", ""), + "last_used": k.get("last_used") or "", + "added_by": k.get("added_by") or "", + } + ) + return rows new file mode 100644 @@ -0,0 +1,40 @@ +""" +Collect organization-level security settings. + +Reads the org object; several fields are only populated when the token has org +admin access. +""" + +import requests + + +def org_security(org, cfg): + resp = requests.get( + f"https://api.github.com/orgs/{org}", + headers=cfg["headers"], + timeout=cfg["timeout"], + ) + resp.raise_for_status() + o = resp.json() + return [ + { + "org": org, + "two_factor_required": o.get("two_factor_requirement_enabled"), + "default_repo_permission": o.get("default_repository_permission"), + "members_can_create_repos": o.get("members_can_create_repositories"), + "members_can_create_public_repos": o.get( + "members_can_create_public_repositories" + ), + "members_can_create_pages": o.get("members_can_create_pages"), + "web_commit_signoff_required": o.get("web_commit_signoff_required"), + "advanced_security_for_new_repos": o.get( + "advanced_security_enabled_for_new_repositories" + ), + "secret_scanning_for_new_repos": o.get( + "secret_scanning_enabled_for_new_repositories" + ), + "secret_scanning_push_protection_for_new_repos": o.get( + "secret_scanning_push_protection_enabled_for_new_repositories" + ), + } + ] new file mode 100644 @@ -0,0 +1,63 @@ +""" +Collect open secret-scanning and Dependabot alerts across the organization. + +Both require GitHub Advanced Security (or public repos) and admin access. If the +org or token can't reach them, the collector returns an empty list with a +warning instead of failing the run. +""" + +import sys + +import requests + +from .api import paginate + + +def secret_scanning(org, cfg): + return _org_alerts(org, cfg, "secret-scanning", _secret_row, "secret scanning") + + +def dependabot_alerts(org, cfg): + return _org_alerts(org, cfg, "dependabot", _dependabot_row, "Dependabot") + + +def _org_alerts(org, cfg, kind, row_fn, label): + try: + alerts = paginate( + f"https://api.github.com/orgs/{org}/{kind}/alerts", cfg, {"state": "open"} + ) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code in (403, 404): + print( + f"Warning: {label} alerts require GitHub Advanced Security and " + "org admin access -- skipping.", + file=sys.stderr, + ) + return [] + raise + return [row_fn(a) for a in alerts] + + +def _secret_row(alert): + return { + "repo": alert.get("repository", {}).get("full_name", ""), + "secret_type": alert.get("secret_type_display_name") + or alert.get("secret_type", ""), + "state": alert.get("state", ""), + "created_at": alert.get("created_at", ""), + "html_url": alert.get("html_url", ""), + } + + +def _dependabot_row(alert): + dependency = alert.get("dependency", {}) + advisory = alert.get("security_advisory", {}) + return { + "repo": alert.get("repository", {}).get("full_name", ""), + "package": dependency.get("package", {}).get("name", ""), + "severity": advisory.get("severity", ""), + "summary": advisory.get("summary", ""), + "state": alert.get("state", ""), + "created_at": alert.get("created_at", ""), + "html_url": alert.get("html_url", ""), + } new file mode 100644 @@ -0,0 +1,52 @@ +""" +Collect organization and repository webhooks. + +Flags webhooks that deliver over plain HTTP or with SSL verification disabled. +""" + +import sys +from urllib.parse import urlparse + +import requests + +from .api import paginate + + +def webhooks(org, cfg): + rows = [ + _hook_row("org", h) + for h in paginate(f"https://api.github.com/orgs/{org}/hooks", cfg) + ] + + for repo in paginate(f"https://api.github.com/orgs/{org}/repos", cfg): + name = repo["name"] + try: + hooks = paginate(f"https://api.github.com/repos/{org}/{name}/hooks", cfg) + except requests.HTTPError as e: + if e.response is not None and e.response.status_code in (403, 404): + print( + f" Skipping {name}: hooks endpoint returned " + f"{e.response.status_code}", + file=sys.stderr, + ) + continue + raise + rows.extend(_hook_row(f"repo:{name}", h) for h in hooks) + + return rows + + +def _hook_row(scope, hook): + config = hook.get("config", {}) + url = config.get("url", "") + return { + "scope": scope, + "url": url, + "insecure_url": urlparse(url).scheme == "http", + "ssl_verification": "disabled" + if str(config.get("insecure_ssl", "0")) == "1" + else "enabled", + "content_type": config.get("content_type", ""), + "events": ", ".join(hook.get("events", [])), + "active": hook.get("active"), + } @@ -27,7 +27,11 @@ from applications.github.collectors import ( audit_log, branch_protections, commits, + deploy_keys, members, + org_settings, + security_alerts, + webhooks, ) from applications.github.reporters import csv_reporter @@ -85,6 +89,28 @@ CHECKS: list[Check] = [ "branch_protections.csv", ), Check("commits", "Commits", commits.commits, "commits.csv", arg="branch"), + Check( + "org_security", + "Org security settings", + org_settings.org_security, + "org_security.csv", + ), + Check("webhooks", "Webhooks", webhooks.webhooks, "webhooks.csv"), + Check("deploy_keys", "Deploy keys", deploy_keys.deploy_keys, "deploy_keys.csv"), + Check( + "secret_scanning", + "Secret scanning alerts", + security_alerts.secret_scanning, + "secret_scanning.csv", + note="requires GitHub Advanced Security", + ), + Check( + "dependabot_alerts", + "Dependabot alerts", + security_alerts.dependabot_alerts, + "dependabot_alerts.csv", + note="requires GitHub Advanced Security", + ), Check( "audit_log", "Audit log (branch/ruleset changes)", @@ -94,7 +120,9 @@ CHECKS: list[Check] = [ ), ] -DEFAULT_SELECTION = [c.key for c in CHECKS if c.key != "audit_log"] +# Off by default: checks needing Advanced Security or Enterprise Cloud. +_OFF_BY_DEFAULT = {"secret_scanning", "dependabot_alerts", "audit_log"} +DEFAULT_SELECTION = [c.key for c in CHECKS if c.key not in _OFF_BY_DEFAULT] # --- Config + output helpers ------------------------------------------------ new file mode 100644 @@ -0,0 +1,172 @@ +"""Unit tests for the new GitHub security collectors, mocking the HTTP layer.""" + +import types + +import pytest +import requests + +from applications.github.collectors import ( + deploy_keys, + org_settings, + security_alerts, + webhooks, +) + +CFG = {"headers": {}, "timeout": 30} + + +class _Resp: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def _http_error(status): + err = requests.HTTPError() + err.response = types.SimpleNamespace(status_code=status) + return err + + +# --- org_security ----------------------------------------------------------- + + +def test_org_security_row(monkeypatch): + payload = { + "two_factor_requirement_enabled": True, + "default_repository_permission": "read", + "members_can_create_repositories": False, + } + monkeypatch.setattr(org_settings.requests, "get", lambda *a, **k: _Resp(payload)) + rows = org_settings.org_security("acme", CFG) + assert len(rows) == 1 + assert rows[0]["two_factor_required"] is True + assert rows[0]["default_repo_permission"] == "read" + assert rows[0]["members_can_create_repos"] is False + + +# --- webhooks --------------------------------------------------------------- + + +def test_webhooks_flags_insecure(monkeypatch): + def fake_paginate(url, cfg, params=None): + if url.endswith("/orgs/acme/hooks"): + return [ + { + "config": {"url": "http://hook.example", "insecure_ssl": "1"}, + "events": ["push"], + "active": True, + } + ] + if url.endswith("/orgs/acme/repos"): + return [{"name": "repo1"}] + if url.endswith("/repos/acme/repo1/hooks"): + return [ + { + "config": {"url": "https://secure.example", "insecure_ssl": "0"}, + "events": ["pull_request"], + "active": True, + } + ] + return [] + + monkeypatch.setattr(webhooks, "paginate", fake_paginate) + rows = webhooks.webhooks("acme", CFG) + + org_hook = next(r for r in rows if r["scope"] == "org") + assert org_hook["insecure_url"] is True + assert org_hook["ssl_verification"] == "disabled" + + repo_hook = next(r for r in rows if r["scope"] == "repo:repo1") + assert repo_hook["insecure_url"] is False + assert repo_hook["ssl_verification"] == "enabled" + + +def test_webhooks_skips_forbidden_repo(monkeypatch): + def fake_paginate(url, cfg, params=None): + if url.endswith("/orgs/acme/hooks"): + return [] + if url.endswith("/orgs/acme/repos"): + return [{"name": "locked"}] + raise _http_error(403) + + monkeypatch.setattr(webhooks, "paginate", fake_paginate) + assert webhooks.webhooks("acme", CFG) == [] + + +# --- deploy_keys ------------------------------------------------------------ + + +def test_deploy_keys_rows(monkeypatch): + def fake_paginate(url, cfg, params=None): + if url.endswith("/orgs/acme/repos"): + return [{"name": "repo1"}] + if url.endswith("/repos/acme/repo1/keys"): + return [{"title": "ci", "read_only": False, "created_at": "2026-01-01"}] + return [] + + monkeypatch.setattr(deploy_keys, "paginate", fake_paginate) + rows = deploy_keys.deploy_keys("acme", CFG) + assert rows == [ + { + "repo": "repo1", + "title": "ci", + "read_only": False, + "created_at": "2026-01-01", + "last_used": "", + "added_by": "", + } + ] + + +# --- security alerts -------------------------------------------------------- + + +def test_secret_scanning_rows(monkeypatch): + monkeypatch.setattr( + security_alerts, + "paginate", + lambda url, cfg, params=None: [ + { + "repository": {"full_name": "acme/repo1"}, + "secret_type_display_name": "AWS Key", + "state": "open", + } + ], + ) + rows = security_alerts.secret_scanning("acme", CFG) + assert rows[0]["repo"] == "acme/repo1" + assert rows[0]["secret_type"] == "AWS Key" + + +def test_dependabot_rows(monkeypatch): + monkeypatch.setattr( + security_alerts, + "paginate", + lambda url, cfg, params=None: [ + { + "repository": {"full_name": "acme/repo1"}, + "dependency": {"package": {"name": "requests"}}, + "security_advisory": {"severity": "high", "summary": "RCE"}, + "state": "open", + } + ], + ) + rows = security_alerts.dependabot_alerts("acme", CFG) + assert rows[0]["package"] == "requests" + assert rows[0]["severity"] == "high" + + +@pytest.mark.parametrize( + "fn", [security_alerts.secret_scanning, security_alerts.dependabot_alerts] +) +def test_alerts_skip_without_advanced_security(monkeypatch, fn): + def raise_403(url, cfg, params=None): + raise _http_error(403) + + monkeypatch.setattr(security_alerts, "paginate", raise_403) + assert fn("acme", CFG) == []