Commit 0e167b5102
Unsigned
Layout: unified · split
README.org +1 −1
| @@ -18,7 +18,7 @@ connection details, choose which checks to run, and watch live progress. | ||
| 18 | 18 | | Directory | Description | |
| 19 | 19 | |----------------------+------------------------------------------------------------------------------| |
| 20 | 20 | | =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 | | |
| 22 | 22 | | =applications/gitlab/= | GitLab group/project members, branch protections, approvals, pipelines, audit events | |
| 23 | 23 | | =databases/mongo/= | MongoDB admin enumeration | |
| 24 | 24 | | =databases/mysql/= | MySQL admin and password queries | |
applications/github/README.md +9 −1
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | > **NOTE**: The PAT used across all scripts needs the following minimum permissions: |
| 2 | 2 | > - 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. | |
| 4 | 7 | > - Audit log collection also requires GitHub Enterprise Cloud. Classic PATs need |
| 5 | 8 | > `read:audit_log`; fine-grained tokens need Organization Administration (read). |
| 6 | 9 | |
| @@ -46,6 +49,11 @@ Creates a directory: `<out>/github_audit_<org>_<YYYY-MM-DD>/` | ||
| 46 | 49 | | `permission_matrix.csv` | Full user/repo/permission cross-reference | |
| 47 | 50 | | `branch_protections.csv` | Branch protection settings across all repos | |
| 48 | 51 | | `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) | | |
| 49 | 57 | | `audit_log.csv` | Branch protection and repository ruleset audit-log changes from the last 180 days (Enterprise Cloud only) | |
| 50 | 58 | | `summary.txt` | Row counts per section | |
| 51 | 59 | |
applications/github/audit.py +29 −1
| @@ -34,7 +34,16 @@ import sys | ||
| 34 | 34 | from datetime import date |
| 35 | 35 | |
| 36 | 36 | import config |
| 37 | from collectors import audit_log, branch_protections, commits, members | |
| 37 | from collectors import ( | |
| 38 | audit_log, | |
| 39 | branch_protections, | |
| 40 | commits, | |
| 41 | deploy_keys, | |
| 42 | members, | |
| 43 | org_settings, | |
| 44 | security_alerts, | |
| 45 | webhooks, | |
| 46 | ) | |
| 38 | 47 | from reporters import csv_reporter |
| 39 | 48 | |
| 40 | 49 | |
| @@ -144,6 +153,25 @@ def run(): | ||
| 144 | 153 | cfg, |
| 145 | 154 | ) |
| 146 | 155 | 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 | ) | |
| 147 | 175 | collect( |
| 148 | 176 | "Audit log branch/ruleset changes", |
| 149 | 177 | 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 | ||
| 3 | import sys | |
| 4 | ||
| 5 | import requests | |
| 6 | ||
| 7 | from .api import paginate | |
| 8 | ||
| 9 | ||
| 10 | def 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 | """ | |
| 2 | Collect organization-level security settings. | |
| 3 | ||
| 4 | Reads the org object; several fields are only populated when the token has org | |
| 5 | admin access. | |
| 6 | """ | |
| 7 | ||
| 8 | import requests | |
| 9 | ||
| 10 | ||
| 11 | def 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 | """ | |
| 2 | Collect open secret-scanning and Dependabot alerts across the organization. | |
| 3 | ||
| 4 | Both require GitHub Advanced Security (or public repos) and admin access. If the | |
| 5 | org or token can't reach them, the collector returns an empty list with a | |
| 6 | warning instead of failing the run. | |
| 7 | """ | |
| 8 | ||
| 9 | import sys | |
| 10 | ||
| 11 | import requests | |
| 12 | ||
| 13 | from .api import paginate | |
| 14 | ||
| 15 | ||
| 16 | def secret_scanning(org, cfg): | |
| 17 | return _org_alerts(org, cfg, "secret-scanning", _secret_row, "secret scanning") | |
| 18 | ||
| 19 | ||
| 20 | def dependabot_alerts(org, cfg): | |
| 21 | return _org_alerts(org, cfg, "dependabot", _dependabot_row, "Dependabot") | |
| 22 | ||
| 23 | ||
| 24 | def _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 | ||
| 41 | def _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 | ||
| 52 | def _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 | """ | |
| 2 | Collect organization and repository webhooks. | |
| 3 | ||
| 4 | Flags webhooks that deliver over plain HTTP or with SSL verification disabled. | |
| 5 | """ | |
| 6 | ||
| 7 | import sys | |
| 8 | from urllib.parse import urlparse | |
| 9 | ||
| 10 | import requests | |
| 11 | ||
| 12 | from .api import paginate | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ||
| 39 | def _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 ( | ||
| 27 | 27 | audit_log, |
| 28 | 28 | branch_protections, |
| 29 | 29 | commits, |
| 30 | deploy_keys, | |
| 30 | 31 | members, |
| 32 | org_settings, | |
| 33 | security_alerts, | |
| 34 | webhooks, | |
| 31 | 35 | ) |
| 32 | 36 | from applications.github.reporters import csv_reporter |
| 33 | 37 | |
| @@ -85,6 +89,28 @@ CHECKS: list[Check] = [ | ||
| 85 | 89 | "branch_protections.csv", |
| 86 | 90 | ), |
| 87 | 91 | 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 | ), | |
| 88 | 114 | Check( |
| 89 | 115 | "audit_log", |
| 90 | 116 | "Audit log (branch/ruleset changes)", |
| @@ -94,7 +120,9 @@ CHECKS: list[Check] = [ | ||
| 94 | 120 | ), |
| 95 | 121 | ] |
| 96 | 122 | |
| 97 | DEFAULT_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"} | |
| 125 | DEFAULT_SELECTION = [c.key for c in CHECKS if c.key not in _OFF_BY_DEFAULT] | |
| 98 | 126 | |
| 99 | 127 | |
| 100 | 128 | # --- 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 | ||
| 3 | import types | |
| 4 | ||
| 5 | import pytest | |
| 6 | import requests | |
| 7 | ||
| 8 | from applications.github.collectors import ( | |
| 9 | deploy_keys, | |
| 10 | org_settings, | |
| 11 | security_alerts, | |
| 12 | webhooks, | |
| 13 | ) | |
| 14 | ||
| 15 | CFG = {"headers": {}, "timeout": 30} | |
| 16 | ||
| 17 | ||
| 18 | class _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 | ||
| 29 | def _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 | ||
| 38 | def 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 | ||
| 55 | def 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 | ||
| 89 | def 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 | ||
| 104 | def 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 | ||
| 129 | def 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 | ||
| 146 | def 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 | ) | |
| 167 | def 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) == [] | |