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

feat: add GitHub security posture checks

Deepen the GitHub audit with five new collectors:

- org_security: org settings (2FA requirement, default permission, repo
  creation, secret-scanning defaults).
- webhooks: org and per-repo webhooks, flagging plain-HTTP delivery and disabled
  SSL verification.
- deploy_keys: deploy keys across all repos (read-only vs read-write).
- secret_scanning / dependabot_alerts: open Advanced Security alerts; skip
  gracefully (403/404) when GHAS or permissions are unavailable, off by default.

Wire them into github_runner and audit.py, add mocked-HTTP unit tests for the
new collectors (the first collector-level tests for GitHub), and update the
README scope/permission notes.
 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(-)

diff --git a/README.org b/README.org
index b7475f1..4725918 100644
--- a/README.org
+++ b/README.org
@@ -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                                             |
diff --git a/applications/github/README.md b/applications/github/README.md
index 75fe202..30c041a 100644
--- a/applications/github/README.md
+++ b/applications/github/README.md
@@ -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 |
 
diff --git a/applications/github/audit.py b/applications/github/audit.py
index 7870637..05ec652 100644
--- a/applications/github/audit.py
+++ b/applications/github/audit.py
@@ -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,
diff --git a/applications/github/collectors/deploy_keys.py b/applications/github/collectors/deploy_keys.py
new file mode 100644
index 0000000..a675c9a
--- /dev/null
+++ b/applications/github/collectors/deploy_keys.py
@@ -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
diff --git a/applications/github/collectors/org_settings.py b/applications/github/collectors/org_settings.py
new file mode 100644
index 0000000..e7521fe
--- /dev/null
+++ b/applications/github/collectors/org_settings.py
@@ -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"
+            ),
+        }
+    ]
diff --git a/applications/github/collectors/security_alerts.py b/applications/github/collectors/security_alerts.py
new file mode 100644
index 0000000..9f89f3b
--- /dev/null
+++ b/applications/github/collectors/security_alerts.py
@@ -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", ""),
+    }
diff --git a/applications/github/collectors/webhooks.py b/applications/github/collectors/webhooks.py
new file mode 100644
index 0000000..70c1ea5
--- /dev/null
+++ b/applications/github/collectors/webhooks.py
@@ -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"),
+    }
diff --git a/tui/github_runner.py b/tui/github_runner.py
index f107db9..c216f1e 100644
--- a/tui/github_runner.py
+++ b/tui/github_runner.py
@@ -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 ------------------------------------------------
diff --git a/tui/tests/test_github_collectors.py b/tui/tests/test_github_collectors.py
new file mode 100644
index 0000000..f7ffd67
--- /dev/null
+++ b/tui/tests/test_github_collectors.py
@@ -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) == []