audit-labs/audit-tools

A collection of scripts, queries, and other goodies you can use in an audit. audit automation compliance evidence scripts

Commit 0e167b5102

0e167b510257caaf6241c9a2d20f2df4a89ac8d8

parent: 69a3ab8a91

Unsigned

cmc <hello@cleberg.net> · 2026-07-29 16:43 UTC

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.

Layout: unified · split

README.org +1 −1
@@ -18,7 +18,7 @@ connection details, choose which checks to run, and watch live progress.
1818| Directory | Description |
1919|----------------------+------------------------------------------------------------------------------|
2020| =applications/aws/= | AWS IAM users, password policy, and S3 bucket analysis |
21| =applications/github/= | GitHub admin enumeration, audit log, branch protections, and commit analysis |
21| =applications/github/= | GitHub admin enumeration, org security settings, webhooks, deploy keys, secret-scanning/Dependabot alerts, audit log, branch protections, commits |
2222| =applications/gitlab/= | GitLab group/project members, branch protections, approvals, pipelines, audit events |
2323| =databases/mongo/= | MongoDB admin enumeration |
2424| =databases/mysql/= | MySQL admin and password queries |
applications/github/README.md +9 −1
@@ -1,6 +1,9 @@
11> **NOTE**: The PAT used across all scripts needs the following minimum permissions:
22> - Repository: Actions (read), Contents (read), Metadata (read), Workflows (read)
3> - Organization: Administration (read), Members (read)
3> - Organization: Administration (read), Members (read), Webhooks (read)
4> - Secret scanning and Dependabot alerts require GitHub Advanced Security and
5> the corresponding read permissions; they are skipped with a warning if
6> unavailable.
47> - Audit log collection also requires GitHub Enterprise Cloud. Classic PATs need
58> `read:audit_log`; fine-grained tokens need Organization Administration (read).
69
@@ -46,6 +49,11 @@ Creates a directory: `<out>/github_audit_<org>_<YYYY-MM-DD>/`
4649| `permission_matrix.csv` | Full user/repo/permission cross-reference |
4750| `branch_protections.csv` | Branch protection settings across all repos |
4851| `commits.csv` | Commit history across all repos for the target branch |
52| `org_security.csv` | Org security settings (2FA requirement, default permission, repo creation, secret scanning defaults) |
53| `webhooks.csv` | Org and per-repo webhooks, flagging plain-HTTP delivery and disabled SSL verification |
54| `deploy_keys.csv` | Deploy keys across all repos (read-only vs read-write, last used) |
55| `secret_scanning.csv` | Open secret-scanning alerts (Advanced Security) |
56| `dependabot_alerts.csv` | Open Dependabot alerts with severity (Advanced Security) |
4957| `audit_log.csv` | Branch protection and repository ruleset audit-log changes from the last 180 days (Enterprise Cloud only) |
5058| `summary.txt` | Row counts per section |
5159
applications/github/audit.py +29 −1
@@ -34,7 +34,16 @@ import sys
3434from datetime import date
3535
3636import config
37from collectors import audit_log, branch_protections, commits, members
37from collectors import (
38 audit_log,
39 branch_protections,
40 commits,
41 deploy_keys,
42 members,
43 org_settings,
44 security_alerts,
45 webhooks,
46)
3847from reporters import csv_reporter
3948
4049
@@ -144,6 +153,25 @@ def run():
144153 cfg,
145154 )
146155 collect("Commits", commits.commits, "commits.csv", org, cfg, args.branch)
156 collect(
157 "Org security settings", org_settings.org_security, "org_security.csv", org, cfg
158 )
159 collect("Webhooks", webhooks.webhooks, "webhooks.csv", org, cfg)
160 collect("Deploy keys", deploy_keys.deploy_keys, "deploy_keys.csv", org, cfg)
161 collect(
162 "Secret scanning alerts",
163 security_alerts.secret_scanning,
164 "secret_scanning.csv",
165 org,
166 cfg,
167 )
168 collect(
169 "Dependabot alerts",
170 security_alerts.dependabot_alerts,
171 "dependabot_alerts.csv",
172 org,
173 cfg,
174 )
147175 collect(
148176 "Audit log branch/ruleset changes",
149177 audit_log.audit_log,
applications/github/collectors/deploy_keys.py added +36
@@ -0,0 +1,36 @@
1"""Collect deploy keys across all repositories in an org."""
2
3import sys
4
5import requests
6
7from .api import paginate
8
9
10def deploy_keys(org, cfg):
11 rows = []
12 for repo in paginate(f"https://api.github.com/orgs/{org}/repos", cfg):
13 name = repo["name"]
14 try:
15 keys = paginate(f"https://api.github.com/repos/{org}/{name}/keys", cfg)
16 except requests.HTTPError as e:
17 if e.response is not None and e.response.status_code in (403, 404):
18 print(
19 f" Skipping {name}: keys endpoint returned "
20 f"{e.response.status_code}",
21 file=sys.stderr,
22 )
23 continue
24 raise
25 for k in keys:
26 rows.append(
27 {
28 "repo": name,
29 "title": k.get("title", ""),
30 "read_only": k.get("read_only"),
31 "created_at": k.get("created_at", ""),
32 "last_used": k.get("last_used") or "",
33 "added_by": k.get("added_by") or "",
34 }
35 )
36 return rows
applications/github/collectors/org_settings.py added +40
@@ -0,0 +1,40 @@
1"""
2Collect organization-level security settings.
3
4Reads the org object; several fields are only populated when the token has org
5admin access.
6"""
7
8import requests
9
10
11def org_security(org, cfg):
12 resp = requests.get(
13 f"https://api.github.com/orgs/{org}",
14 headers=cfg["headers"],
15 timeout=cfg["timeout"],
16 )
17 resp.raise_for_status()
18 o = resp.json()
19 return [
20 {
21 "org": org,
22 "two_factor_required": o.get("two_factor_requirement_enabled"),
23 "default_repo_permission": o.get("default_repository_permission"),
24 "members_can_create_repos": o.get("members_can_create_repositories"),
25 "members_can_create_public_repos": o.get(
26 "members_can_create_public_repositories"
27 ),
28 "members_can_create_pages": o.get("members_can_create_pages"),
29 "web_commit_signoff_required": o.get("web_commit_signoff_required"),
30 "advanced_security_for_new_repos": o.get(
31 "advanced_security_enabled_for_new_repositories"
32 ),
33 "secret_scanning_for_new_repos": o.get(
34 "secret_scanning_enabled_for_new_repositories"
35 ),
36 "secret_scanning_push_protection_for_new_repos": o.get(
37 "secret_scanning_push_protection_enabled_for_new_repositories"
38 ),
39 }
40 ]
applications/github/collectors/security_alerts.py added +63
@@ -0,0 +1,63 @@
1"""
2Collect open secret-scanning and Dependabot alerts across the organization.
3
4Both require GitHub Advanced Security (or public repos) and admin access. If the
5org or token can't reach them, the collector returns an empty list with a
6warning instead of failing the run.
7"""
8
9import sys
10
11import requests
12
13from .api import paginate
14
15
16def secret_scanning(org, cfg):
17 return _org_alerts(org, cfg, "secret-scanning", _secret_row, "secret scanning")
18
19
20def dependabot_alerts(org, cfg):
21 return _org_alerts(org, cfg, "dependabot", _dependabot_row, "Dependabot")
22
23
24def _org_alerts(org, cfg, kind, row_fn, label):
25 try:
26 alerts = paginate(
27 f"https://api.github.com/orgs/{org}/{kind}/alerts", cfg, {"state": "open"}
28 )
29 except requests.HTTPError as e:
30 if e.response is not None and e.response.status_code in (403, 404):
31 print(
32 f"Warning: {label} alerts require GitHub Advanced Security and "
33 "org admin access -- skipping.",
34 file=sys.stderr,
35 )
36 return []
37 raise
38 return [row_fn(a) for a in alerts]
39
40
41def _secret_row(alert):
42 return {
43 "repo": alert.get("repository", {}).get("full_name", ""),
44 "secret_type": alert.get("secret_type_display_name")
45 or alert.get("secret_type", ""),
46 "state": alert.get("state", ""),
47 "created_at": alert.get("created_at", ""),
48 "html_url": alert.get("html_url", ""),
49 }
50
51
52def _dependabot_row(alert):
53 dependency = alert.get("dependency", {})
54 advisory = alert.get("security_advisory", {})
55 return {
56 "repo": alert.get("repository", {}).get("full_name", ""),
57 "package": dependency.get("package", {}).get("name", ""),
58 "severity": advisory.get("severity", ""),
59 "summary": advisory.get("summary", ""),
60 "state": alert.get("state", ""),
61 "created_at": alert.get("created_at", ""),
62 "html_url": alert.get("html_url", ""),
63 }
applications/github/collectors/webhooks.py added +52
@@ -0,0 +1,52 @@
1"""
2Collect organization and repository webhooks.
3
4Flags webhooks that deliver over plain HTTP or with SSL verification disabled.
5"""
6
7import sys
8from urllib.parse import urlparse
9
10import requests
11
12from .api import paginate
13
14
15def webhooks(org, cfg):
16 rows = [
17 _hook_row("org", h)
18 for h in paginate(f"https://api.github.com/orgs/{org}/hooks", cfg)
19 ]
20
21 for repo in paginate(f"https://api.github.com/orgs/{org}/repos", cfg):
22 name = repo["name"]
23 try:
24 hooks = paginate(f"https://api.github.com/repos/{org}/{name}/hooks", cfg)
25 except requests.HTTPError as e:
26 if e.response is not None and e.response.status_code in (403, 404):
27 print(
28 f" Skipping {name}: hooks endpoint returned "
29 f"{e.response.status_code}",
30 file=sys.stderr,
31 )
32 continue
33 raise
34 rows.extend(_hook_row(f"repo:{name}", h) for h in hooks)
35
36 return rows
37
38
39def _hook_row(scope, hook):
40 config = hook.get("config", {})
41 url = config.get("url", "")
42 return {
43 "scope": scope,
44 "url": url,
45 "insecure_url": urlparse(url).scheme == "http",
46 "ssl_verification": "disabled"
47 if str(config.get("insecure_ssl", "0")) == "1"
48 else "enabled",
49 "content_type": config.get("content_type", ""),
50 "events": ", ".join(hook.get("events", [])),
51 "active": hook.get("active"),
52 }
tui/github_runner.py +29 −1
@@ -27,7 +27,11 @@ from applications.github.collectors import (
2727 audit_log,
2828 branch_protections,
2929 commits,
30 deploy_keys,
3031 members,
32 org_settings,
33 security_alerts,
34 webhooks,
3135)
3236from applications.github.reporters import csv_reporter
3337
@@ -85,6 +89,28 @@ CHECKS: list[Check] = [
8589 "branch_protections.csv",
8690 ),
8791 Check("commits", "Commits", commits.commits, "commits.csv", arg="branch"),
92 Check(
93 "org_security",
94 "Org security settings",
95 org_settings.org_security,
96 "org_security.csv",
97 ),
98 Check("webhooks", "Webhooks", webhooks.webhooks, "webhooks.csv"),
99 Check("deploy_keys", "Deploy keys", deploy_keys.deploy_keys, "deploy_keys.csv"),
100 Check(
101 "secret_scanning",
102 "Secret scanning alerts",
103 security_alerts.secret_scanning,
104 "secret_scanning.csv",
105 note="requires GitHub Advanced Security",
106 ),
107 Check(
108 "dependabot_alerts",
109 "Dependabot alerts",
110 security_alerts.dependabot_alerts,
111 "dependabot_alerts.csv",
112 note="requires GitHub Advanced Security",
113 ),
88114 Check(
89115 "audit_log",
90116 "Audit log (branch/ruleset changes)",
@@ -94,7 +120,9 @@ CHECKS: list[Check] = [
94120 ),
95121]
96122
97DEFAULT_SELECTION = [c.key for c in CHECKS if c.key != "audit_log"]
123# Off by default: checks needing Advanced Security or Enterprise Cloud.
124_OFF_BY_DEFAULT = {"secret_scanning", "dependabot_alerts", "audit_log"}
125DEFAULT_SELECTION = [c.key for c in CHECKS if c.key not in _OFF_BY_DEFAULT]
98126
99127
100128# --- Config + output helpers ------------------------------------------------
tui/tests/test_github_collectors.py added +172
@@ -0,0 +1,172 @@
1"""Unit tests for the new GitHub security collectors, mocking the HTTP layer."""
2
3import types
4
5import pytest
6import requests
7
8from applications.github.collectors import (
9 deploy_keys,
10 org_settings,
11 security_alerts,
12 webhooks,
13)
14
15CFG = {"headers": {}, "timeout": 30}
16
17
18class _Resp:
19 def __init__(self, payload):
20 self._payload = payload
21
22 def raise_for_status(self):
23 pass
24
25 def json(self):
26 return self._payload
27
28
29def _http_error(status):
30 err = requests.HTTPError()
31 err.response = types.SimpleNamespace(status_code=status)
32 return err
33
34
35# --- org_security -----------------------------------------------------------
36
37
38def test_org_security_row(monkeypatch):
39 payload = {
40 "two_factor_requirement_enabled": True,
41 "default_repository_permission": "read",
42 "members_can_create_repositories": False,
43 }
44 monkeypatch.setattr(org_settings.requests, "get", lambda *a, **k: _Resp(payload))
45 rows = org_settings.org_security("acme", CFG)
46 assert len(rows) == 1
47 assert rows[0]["two_factor_required"] is True
48 assert rows[0]["default_repo_permission"] == "read"
49 assert rows[0]["members_can_create_repos"] is False
50
51
52# --- webhooks ---------------------------------------------------------------
53
54
55def test_webhooks_flags_insecure(monkeypatch):
56 def fake_paginate(url, cfg, params=None):
57 if url.endswith("/orgs/acme/hooks"):
58 return [
59 {
60 "config": {"url": "http://hook.example", "insecure_ssl": "1"},
61 "events": ["push"],
62 "active": True,
63 }
64 ]
65 if url.endswith("/orgs/acme/repos"):
66 return [{"name": "repo1"}]
67 if url.endswith("/repos/acme/repo1/hooks"):
68 return [
69 {
70 "config": {"url": "https://secure.example", "insecure_ssl": "0"},
71 "events": ["pull_request"],
72 "active": True,
73 }
74 ]
75 return []
76
77 monkeypatch.setattr(webhooks, "paginate", fake_paginate)
78 rows = webhooks.webhooks("acme", CFG)
79
80 org_hook = next(r for r in rows if r["scope"] == "org")
81 assert org_hook["insecure_url"] is True
82 assert org_hook["ssl_verification"] == "disabled"
83
84 repo_hook = next(r for r in rows if r["scope"] == "repo:repo1")
85 assert repo_hook["insecure_url"] is False
86 assert repo_hook["ssl_verification"] == "enabled"
87
88
89def test_webhooks_skips_forbidden_repo(monkeypatch):
90 def fake_paginate(url, cfg, params=None):
91 if url.endswith("/orgs/acme/hooks"):
92 return []
93 if url.endswith("/orgs/acme/repos"):
94 return [{"name": "locked"}]
95 raise _http_error(403)
96
97 monkeypatch.setattr(webhooks, "paginate", fake_paginate)
98 assert webhooks.webhooks("acme", CFG) == []
99
100
101# --- deploy_keys ------------------------------------------------------------
102
103
104def test_deploy_keys_rows(monkeypatch):
105 def fake_paginate(url, cfg, params=None):
106 if url.endswith("/orgs/acme/repos"):
107 return [{"name": "repo1"}]
108 if url.endswith("/repos/acme/repo1/keys"):
109 return [{"title": "ci", "read_only": False, "created_at": "2026-01-01"}]
110 return []
111
112 monkeypatch.setattr(deploy_keys, "paginate", fake_paginate)
113 rows = deploy_keys.deploy_keys("acme", CFG)
114 assert rows == [
115 {
116 "repo": "repo1",
117 "title": "ci",
118 "read_only": False,
119 "created_at": "2026-01-01",
120 "last_used": "",
121 "added_by": "",
122 }
123 ]
124
125
126# --- security alerts --------------------------------------------------------
127
128
129def test_secret_scanning_rows(monkeypatch):
130 monkeypatch.setattr(
131 security_alerts,
132 "paginate",
133 lambda url, cfg, params=None: [
134 {
135 "repository": {"full_name": "acme/repo1"},
136 "secret_type_display_name": "AWS Key",
137 "state": "open",
138 }
139 ],
140 )
141 rows = security_alerts.secret_scanning("acme", CFG)
142 assert rows[0]["repo"] == "acme/repo1"
143 assert rows[0]["secret_type"] == "AWS Key"
144
145
146def test_dependabot_rows(monkeypatch):
147 monkeypatch.setattr(
148 security_alerts,
149 "paginate",
150 lambda url, cfg, params=None: [
151 {
152 "repository": {"full_name": "acme/repo1"},
153 "dependency": {"package": {"name": "requests"}},
154 "security_advisory": {"severity": "high", "summary": "RCE"},
155 "state": "open",
156 }
157 ],
158 )
159 rows = security_alerts.dependabot_alerts("acme", CFG)
160 assert rows[0]["package"] == "requests"
161 assert rows[0]["severity"] == "high"
162
163
164@pytest.mark.parametrize(
165 "fn", [security_alerts.secret_scanning, security_alerts.dependabot_alerts]
166)
167def test_alerts_skip_without_advanced_security(monkeypatch, fn):
168 def raise_403(url, cfg, params=None):
169 raise _http_error(403)
170
171 monkeypatch.setattr(security_alerts, "paginate", raise_403)
172 assert fn("acme", CFG) == []