audit-labs/audit-tools

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

Commit 31f6eb371d

31f6eb371d1a4ad49603abc1bded44a24e668721

parent: 4221777e89

Unsigned

cmc <hello@cleberg.net> · 2026-07-29 04:13 UTC

feat: add interactive TUI audit app with GitHub and GitLab support

Add a Textual terminal UI (audit_tui.py) that walks users through running an
audit: pick a platform, enter connection details, toggle checks, and watch
live progress. It reuses the existing collectors as an interactive driver.

- tui/: platform-agnostic screens driven by a Platform descriptor
  (tui/platforms.py); per-platform runners (github_runner, gitlab_runner)
  reuse each app's collectors and CSV reporter unchanged.
- Refactor applications/gitlab into a collector package (collectors/,
  reporters/, config.py, audit.py) mirroring GitHub, replacing the standalone
  scripts and their hardcoded tokens with env-var auth
  (GITLAB_TOKEN/GITLAB_GROUP/GITLAB_URL). Premium and self-hosted checks skip
  gracefully and are off by default.
- Namespace both collector packages as applications.<platform>.collectors so
  they coexist in one process; both audit.py CLIs still run standalone.
- Add conftest.py so the test suite runs under plain pytest; add offline tests
  for both runners and headless smoke tests driving the app through every
  screen. Add textual to requirements.

Layout: unified · split

README.org +12 −1
@@ -13,7 +13,7 @@ specific audit environments.
1313|----------------------+------------------------------------------------------------------------------|
1414| =applications/aws/= | AWS IAM users, password policy, and S3 bucket analysis |
1515| =applications/github/= | GitHub admin enumeration, audit log, branch protections, and commit analysis |
16| =applications/gitlab/= | GitLab user provisioning, branch protections, approvals, pipelines, and more |
16| =applications/gitlab/= | GitLab group/project members, branch protections, approvals, pipelines, audit events |
1717| =databases/mongo/= | MongoDB admin enumeration |
1818| =databases/mysql/= | MySQL admin and password queries |
1919| =databases/oracle/= | Oracle admin queries |
@@ -22,6 +22,7 @@ specific audit environments.
2222| =os/linux/= | Linux OS reporting, password file analysis, and SSH root login checks |
2323| =project_management/= | Audit project tracking dashboards (Alteryx, Dash, Power BI) |
2424| =sampling/= | Random and stratified sampling tools |
25| =tui/= | Interactive terminal UI that walks you through running an audit |
2526
2627** Getting Started
2728
@@ -57,6 +58,16 @@ python sampling/sample.py
5758Output will be shown in the terminal or saved to a file, depending on the
5859script.
5960
61*Interactive TUI*
62
63To pick a platform and be walked through an audit interactively:
64
65#+begin_src bash
66python audit_tui.py
67#+end_src
68
69See =tui/README.md= for details. GitHub and GitLab are supported.
70
6071** Contributing
6172
6273Contributions are welcome. You can contribute by:
applications/__init__.py added
applications/github/__init__.py added
applications/gitlab/README.md +42 −144
@@ -1,160 +1,58 @@
1# `approvals.py`
1> **NOTE**: The token used across all collectors needs at least the `read_api`
2> scope. Some checks need more:
3> - **Approval rules** and **audit events** require a GitLab Premium or Ultimate
4> subscription.
5> - **Password policy** reads instance application settings, which require an
6> admin token on a self-hosted instance (not available on GitLab.com).
7>
8> Checks that are unavailable are skipped with a warning; the rest still run.
29
3\\This script requires an active Premium or Ultimate subscription.\*\\
10---
411
5``` bash
6python ./approvals.py
7```
12# `audit.py` — Unified GitLab Audit Tool
813
9``` text
10Rule: All Members
11 Approvals Required: 1
12 Rule type: any_approver
13Rule: Default
14 Approvals Required: 1
15 Rule type: regular
16 Protected Branch: master
17 Eligible Approver: Christian Cleberg
18```
14Runs all collectors against a GitLab group (including its subgroups) and writes
15a timestamped audit package to disk.
1916
20# `branch_protections.py`
17## Setup
2118
22``` bash
23python ./branch_protections.py
19```bash
20export GITLAB_TOKEN=your_token
21export GITLAB_GROUP=your_group_id_or_path
22# Self-hosted only:
23export GITLAB_URL=https://gitlab.example.com/api/v4
2424```
2525
26``` json
27[
28 {
29 "id": 148448212,
30 "name": "main",
31 "push_access_levels": [
32 {
33 "id": 185900194,
34 "access_level": 40,
35 "access_level_description": "Maintainers",
36 "deploy_key_id": null,
37 "user_id": null,
38 "group_id": null
39 }
40 ],
41 "merge_access_levels": [
42 {
43 "id": 156461000,
44 "access_level": 40,
45 "access_level_description": "Maintainers",
46 "user_id": null,
47 "group_id": null
48 }
49 ],
50 "allow_force_push": false,
51 "unprotect_access_levels": [],
52 "code_owner_approval_required": false,
53 "inherited": false
54 }
55]
56```
26## Usage
5727
58# `passwords.py`
28```bash
29# Basic run — uses GITLAB_TOKEN and GITLAB_GROUP from environment
30python audit.py
5931
60**This script does not apply to GitLab.com. This is for self-hosted
61instances only.**
32# Override group, set output directory
33python audit.py --group my-group --out ./output
6234
63``` bash
64python ./passwords.py
35# Point at a self-hosted instance
36python audit.py --url https://gitlab.example.com/api/v4
6537```
6638
67``` text
68# TODO: Need access to a self-hosted version of GitLab to test this out.
69```
39The group may be a numeric ID (`1234567`) or a URL path (`my-group/sub-group`).
7040
71# `pipelines.py`
41## Output
7242
73``` bash
74python ./pipelines.py
75```
76
77``` text
78Pipeline ID: 1754222228
79 Status: failed
80 Ref: master
81 Created At: 2025-04-06T03:39:15.065Z
82 Duration: N/A seconds
83 Configuration: N/A
84Pipeline ID: 1754221831
85 Status: failed
86 Ref: pr-1
87 Created At: 2025-04-06T03:37:42.333Z
88 Duration: N/A seconds
89 Configuration: N/A
90Pipeline ID: 1754220271
91 Status: failed
92 Ref: pr-1
93 Created At: 2025-04-06T03:33:38.606Z
94 Duration: N/A seconds
95 Configuration: N/A
96Pipeline ID: 1754214637
97 Status: failed
98 Ref: master
99 Created At: 2025-04-06T03:21:39.902Z
100 Duration: N/A seconds
101 Configuration: N/A
102```
103
104# `provisioning.py`
105
106\\This script requires an active Premium or Ultimate subscription.\*\\
107
108``` bash
109python ./provisioning.py
110```
111
112``` text
113Group: 105300140
114 2025-04-08T03:33:17.055Z : Action: member_created, Member: 128029250, Author: 24608590
115```
43Creates a directory: `<out>/gitlab_audit_<group>_<YYYY-MM-DD>/`
11644
117# `repositories.py`
45| File | Contents |
46|---|---|
47| `group_members.csv` | Group members with access level and role |
48| `projects.csv` | All projects in the group and subgroups |
49| `project_members.csv` | Members and access levels for every project |
50| `branch_protections.csv` | Protected-branch settings across all projects |
51| `pipelines.csv` | CI/CD pipeline history across all projects |
52| `approval_rules.csv` | Merge-request approval rules (Premium/Ultimate) |
53| `audit_events.csv` | Group membership audit events (Premium/Ultimate) |
54| `password_policy.csv` | Instance password policy (self-hosted, admin token) |
55| `summary.txt` | Row counts per section |
11856
119``` shell
120python ./repositories.py
121```
122
123``` text
124# User ID Example
125Projects under ID: ccleberg:
126- audit-tools (ID: 68757698)
127- cleberg.net (ID: 68701468)
128
129# Group ID Example
130Projects under ID: phryq:
131- Yoshi Cli (ID: 68757750)
132- pages-demo (ID: 68757186)
133```
134
135# `users.py`
136
137``` bash
138python ./users.py
139```
140
141``` text
142Access Level Roles:
143 0 : No access
144 5 : Minimal access
145 10 : Guest
146 15 : Planner
147 20 : Reporter
148 30 : Developer
149 40 : Maintainer
150 50 : Owner
151 60 : Admin
152
153
154Group 97083755 Members:
155Username: ccleberg, Access Level: 50
156
157Project 68701468 Members:
158Username: ccleberg, Access Level: 50
159Username: project_68701468_bot_2c7ee010a479c0e48cdb4c7c5cfae886, Access Level: 40
160```
57The per-project checks reuse a single enumeration of the group's projects, so
58the group is listed only once per run.
applications/gitlab/__init__.py added
applications/gitlab/approvals.py deleted −38
@@ -1,38 +0,0 @@
1"""
2Extract merge request approval rules and their statuses in GitLab.
3"""
4
5import requests
6
7BASE_URL = "https://gitlab.com/api/v4"
8PRIVATE_TOKEN = "your_access_token"
9PROJECT_ID = "your_project_id"
10TIMEOUT = 30
11
12URL = f"{BASE_URL}/projects/{PROJECT_ID}/approval_rules"
13HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
14
15if __name__ == "__main__":
16 # Get approval rules
17 response = requests.get(URL, headers=HEADERS, timeout=TIMEOUT)
18 if response.status_code == 200:
19 approval_rules = response.json()
20 for rule in approval_rules:
21 name = rule["name"]
22 approvals_required = rule["approvals_required"]
23 rule_type = rule["rule_type"]
24 protected_branches = rule["protected_branches"]
25 eligible_approvers = rule["eligible_approvers"]
26 print(f"Rule: {name}")
27 print(f" Approvals Required: {approvals_required}")
28 print(f" Rule type: {rule_type}")
29 for branch in protected_branches:
30 branch_name = branch["name"]
31 print(f" Protected Branch: {branch_name}")
32 for approver in eligible_approvers:
33 approver_username = approver["name"]
34 print(f" Eligible Approver: {approver_username}")
35 else:
36 print(
37 f"Failed to fetch approval rules: {response.status_code}, {response.text}"
38 )
applications/gitlab/audit.py added +149
@@ -0,0 +1,149 @@
1"""
2GitLab audit CLI.
3
4Runs all collectors against a GitLab group and writes a timestamped audit
5package to an output directory.
6
7Usage:
8 export GITLAB_TOKEN=your_token
9 export GITLAB_GROUP=your_group_id_or_path
10
11 python audit.py
12 python audit.py --group my-group
13 python audit.py --group my-group --out ./output
14 python audit.py --group my-group --url https://gitlab.example.com/api/v4
15
16Output:
17 <out>/gitlab_audit_<group>_<date>/
18 group_members.csv
19 projects.csv
20 project_members.csv
21 branch_protections.csv
22 pipelines.csv
23 approval_rules.csv
24 audit_events.csv
25 password_policy.csv
26 summary.txt
27"""
28
29import argparse
30import os
31import sys
32from datetime import date
33
34import config
35from collectors import (
36 approvals,
37 audit_events,
38 branch_protections,
39 members,
40 pipelines,
41 projects,
42 settings,
43)
44from reporters import csv_reporter
45
46
47def parse_args():
48 parser = argparse.ArgumentParser(
49 description="Generate a GitLab audit package for a group."
50 )
51 parser.add_argument(
52 "--group",
53 help="GitLab group ID or path. Overrides GITLAB_GROUP env var.",
54 )
55 parser.add_argument(
56 "--url",
57 help="GitLab API base URL. Overrides GITLAB_URL env var. "
58 "Default: https://gitlab.com/api/v4",
59 )
60 parser.add_argument(
61 "--out",
62 default="./output",
63 help="Directory to write the audit package into. Default: ./output",
64 )
65 return parser.parse_args()
66
67
68def run():
69 args = parse_args()
70 cfg = config.load(group_override=args.group, base_url_override=args.url)
71 group = cfg["group"]
72
73 safe_group = group.replace("/", "-")
74 output_dir = os.path.join(
75 args.out, f"gitlab_audit_{safe_group}_{date.today().isoformat()}"
76 )
77
78 print(f"GitLab Audit — {group}")
79 print(f"Output directory: {output_dir}")
80 print()
81
82 sections = []
83
84 def collect(label, fn, filename, *fn_args):
85 print(f"Collecting: {label}...")
86 try:
87 rows = fn(*fn_args)
88 except Exception as e:
89 print(f" Error: {e}", file=sys.stderr)
90 rows = []
91 csv_reporter.write(output_dir, filename, rows)
92 sections.append((label, len(rows)))
93 return rows
94
95 print("Enumerating projects (shared cache)...")
96 try:
97 project_cache = projects.fetch_projects(group, cfg)
98 except Exception as e:
99 print(f" Error enumerating projects: {e}", file=sys.stderr)
100 project_cache = []
101
102 collect("Group members", members.group_members, "group_members.csv", group, cfg)
103 collect(
104 "Projects", projects.project_list, "projects.csv", group, cfg, project_cache
105 )
106 collect(
107 "Project members",
108 members.project_members,
109 "project_members.csv",
110 group,
111 cfg,
112 project_cache,
113 )
114 collect(
115 "Branch protections",
116 branch_protections.branch_protections,
117 "branch_protections.csv",
118 group,
119 cfg,
120 project_cache,
121 )
122 collect(
123 "Pipelines", pipelines.pipelines, "pipelines.csv", group, cfg, project_cache
124 )
125 collect(
126 "Approval rules",
127 approvals.approval_rules,
128 "approval_rules.csv",
129 group,
130 cfg,
131 project_cache,
132 )
133 collect("Audit events", audit_events.audit_events, "audit_events.csv", group, cfg)
134 collect(
135 "Password policy",
136 settings.password_policy,
137 "password_policy.csv",
138 group,
139 cfg,
140 )
141
142 print()
143 csv_reporter.write_summary(output_dir, group, sections)
144 print()
145 print("Done.")
146
147
148if __name__ == "__main__":
149 run()
applications/gitlab/branch_protections.py deleted −25
@@ -1,25 +0,0 @@
1"""
2List all branch protection rules and their configurations in GitLab.
3"""
4
5import requests
6import json
7
8BASE_URL = "https://gitlab.com/api/v4"
9PRIVATE_TOKEN = "your_access_token"
10PROJECT_ID = "your_project_id"
11TIMEOUT = 30
12
13URL = f"{BASE_URL}/projects/{PROJECT_ID}/protected_branches"
14HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
15
16if __name__ == "__main__":
17 # Get protected branches
18 response = requests.get(URL, headers=HEADERS, timeout=TIMEOUT)
19 if response.status_code == 200:
20 protected_branches = response.json()
21 print(json.dumps(protected_branches, indent=4))
22 else:
23 print(
24 f"Failed to fetch protected branches: {response.status_code}, {response.text}"
25 )
applications/gitlab/collectors/__init__.py added
applications/gitlab/collectors/api.py added +41
@@ -0,0 +1,41 @@
1"""Shared GitLab API helpers."""
2
3from urllib.parse import quote
4
5import requests
6
7DEFAULT_BASE_URL = "https://gitlab.com/api/v4"
8
9
10def enc(value):
11 """URL-encode a group or project identifier.
12
13 GitLab accepts either a numeric ID or a URL-encoded path (e.g.
14 ``my-group/sub-group``). Numeric IDs pass through unchanged.
15 """
16 return quote(str(value), safe="")
17
18
19def paginate(url, cfg, params=None):
20 """Fetch all pages from a GitLab endpoint using the X-Next-Page header."""
21 results = []
22 p = dict(params or {})
23 p["per_page"] = 100
24 page = 1
25
26 while True:
27 p["page"] = page
28 resp = requests.get(
29 url, headers=cfg["headers"], params=p, timeout=cfg["timeout"]
30 )
31 resp.raise_for_status()
32 data = resp.json()
33 if not data:
34 break
35 results.extend(data)
36 next_page = resp.headers.get("X-Next-Page")
37 if not next_page:
38 break
39 page = int(next_page)
40
41 return results
applications/gitlab/collectors/approvals.py added +48
@@ -0,0 +1,48 @@
1"""
2Collect merge-request approval rules for every project in the group.
3
4Approval rules require a GitLab Premium or Ultimate subscription. Projects that
5return 403/404 (feature unavailable) are skipped with a warning.
6"""
7
8import sys
9
10import requests
11
12from .api import paginate
13
14
15def approval_rules(group, cfg, projects):
16 rows = []
17 for p in projects:
18 try:
19 rules = paginate(
20 f"{cfg['base_url']}/projects/{p['id']}/approval_rules", cfg
21 )
22 except requests.HTTPError as e:
23 if e.response is not None and e.response.status_code in (403, 404):
24 print(
25 f" Skipping {p.get('path_with_namespace', p['id'])}: "
26 f"approval_rules returned {e.response.status_code}",
27 file=sys.stderr,
28 )
29 continue
30 raise
31 for rule in rules:
32 approvers = ", ".join(
33 a.get("name", "") for a in rule.get("eligible_approvers", [])
34 )
35 branches = ", ".join(
36 b.get("name", "") for b in rule.get("protected_branches", [])
37 )
38 rows.append(
39 {
40 "project": p.get("path_with_namespace", ""),
41 "rule": rule.get("name", ""),
42 "rule_type": rule.get("rule_type", ""),
43 "approvals_required": rule.get("approvals_required", 0),
44 "protected_branches": branches or "(all)",
45 "eligible_approvers": approvers or "(none)",
46 }
47 )
48 return rows
applications/gitlab/collectors/audit_events.py added +46
@@ -0,0 +1,46 @@
1"""
2Collect group membership audit events (created / updated / destroyed).
3
4Group audit events require a GitLab Premium or Ultimate subscription. Returns an
5empty list with a warning if the endpoint is unavailable (403/404).
6"""
7
8import sys
9
10import requests
11
12from .api import enc, paginate
13
14MEMBER_ACTIONS = {"member_created", "member_updated", "member_destroyed"}
15
16
17def audit_events(group, cfg):
18 try:
19 events = paginate(f"{cfg['base_url']}/groups/{enc(group)}/audit_events", cfg)
20 except requests.HTTPError as e:
21 if e.response is not None and e.response.status_code in (403, 404):
22 print(
23 "Warning: group audit events require GitLab Premium/Ultimate and "
24 "owner access -- skipping.",
25 file=sys.stderr,
26 )
27 return []
28 raise
29
30 rows = []
31 for event in events:
32 action = event.get("event_name", "")
33 if action not in MEMBER_ACTIONS:
34 continue
35 details = event.get("details", {})
36 rows.append(
37 {
38 "created_at": event.get("created_at", ""),
39 "action": action,
40 "member_id": details.get("member_id", ""),
41 "target": details.get("target_details", ""),
42 "author_id": event.get("author_id", ""),
43 "entity_type": event.get("entity_type", ""),
44 }
45 )
46 return rows
applications/gitlab/collectors/branch_protections.py added +44
@@ -0,0 +1,44 @@
1"""Collect protected-branch settings for every project in the group."""
2
3import sys
4
5import requests
6
7from .api import paginate
8
9
10def _levels(entries):
11 """Summarize an access-level list (push/merge/unprotect) into one string."""
12 return ", ".join(e.get("access_level_description", "") for e in entries) or "(none)"
13
14
15def branch_protections(group, cfg, projects):
16 rows = []
17 for p in projects:
18 try:
19 protected = paginate(
20 f"{cfg['base_url']}/projects/{p['id']}/protected_branches", cfg
21 )
22 except requests.HTTPError as e:
23 if e.response is not None and e.response.status_code in (403, 404):
24 print(
25 f" Skipping {p.get('path_with_namespace', p['id'])}: "
26 f"protected_branches returned {e.response.status_code}",
27 file=sys.stderr,
28 )
29 continue
30 raise
31 for b in protected:
32 rows.append(
33 {
34 "project": p.get("path_with_namespace", ""),
35 "branch": b.get("name", ""),
36 "push_access": _levels(b.get("push_access_levels", [])),
37 "merge_access": _levels(b.get("merge_access_levels", [])),
38 "allow_force_push": b.get("allow_force_push"),
39 "code_owner_approval_required": b.get(
40 "code_owner_approval_required"
41 ),
42 }
43 )
44 return rows
applications/gitlab/collectors/members.py added +72
@@ -0,0 +1,72 @@
1"""
2Collect group and project membership with access levels.
3
4GitLab access levels:
5 0 No access 5 Minimal 10 Guest 15 Planner
6 20 Reporter 30 Developer 40 Maintainer 50 Owner 60 Admin
7"""
8
9import sys
10
11import requests
12
13from .api import enc, paginate
14
15ACCESS_LEVELS = {
16 0: "No access",
17 5: "Minimal",
18 10: "Guest",
19 15: "Planner",
20 20: "Reporter",
21 30: "Developer",
22 40: "Maintainer",
23 50: "Owner",
24 60: "Admin",
25}
26
27
28def _role(level):
29 return ACCESS_LEVELS.get(level, str(level))
30
31
32def group_members(group, cfg):
33 """Group members, including those inherited from parent groups."""
34 members = paginate(f"{cfg['base_url']}/groups/{enc(group)}/members/all", cfg)
35 return [
36 {
37 "username": m["username"],
38 "name": m.get("name", ""),
39 "access_level": m["access_level"],
40 "role": _role(m["access_level"]),
41 "state": m.get("state", ""),
42 }
43 for m in members
44 ]
45
46
47def project_members(group, cfg, projects):
48 """Direct and inherited members of every project in the group."""
49 rows = []
50 for p in projects:
51 try:
52 members = paginate(f"{cfg['base_url']}/projects/{p['id']}/members/all", cfg)
53 except requests.HTTPError as e:
54 if e.response is not None and e.response.status_code in (403, 404):
55 print(
56 f" Skipping {p.get('path_with_namespace', p['id'])}: "
57 f"members returned {e.response.status_code}",
58 file=sys.stderr,
59 )
60 continue
61 raise
62 for m in members:
63 rows.append(
64 {
65 "project": p.get("path_with_namespace", ""),
66 "username": m["username"],
67 "name": m.get("name", ""),
68 "access_level": m["access_level"],
69 "role": _role(m["access_level"]),
70 }
71 )
72 return rows
applications/gitlab/collectors/pipelines.py added +38
@@ -0,0 +1,38 @@
1"""Collect CI/CD pipeline history for every project in the group."""
2
3import sys
4
5import requests
6
7from .api import paginate
8
9
10def pipelines(group, cfg, projects):
11 rows = []
12 for p in projects:
13 try:
14 project_pipelines = paginate(
15 f"{cfg['base_url']}/projects/{p['id']}/pipelines", cfg
16 )
17 except requests.HTTPError as e:
18 if e.response is not None and e.response.status_code in (403, 404):
19 print(
20 f" Skipping {p.get('path_with_namespace', p['id'])}: "
21 f"pipelines returned {e.response.status_code}",
22 file=sys.stderr,
23 )
24 continue
25 raise
26 for pipe in project_pipelines:
27 rows.append(
28 {
29 "project": p.get("path_with_namespace", ""),
30 "pipeline_id": pipe.get("id"),
31 "status": pipe.get("status", ""),
32 "ref": pipe.get("ref", ""),
33 "source": pipe.get("source", ""),
34 "created_at": pipe.get("created_at", ""),
35 "web_url": pipe.get("web_url", ""),
36 }
37 )
38 return rows
applications/gitlab/collectors/projects.py added +33
@@ -0,0 +1,33 @@
1"""
2Enumerate the projects in a GitLab group.
3
4fetch_projects() returns the raw project objects once; the per-project
5collectors reuse that cache to avoid re-listing the group.
6"""
7
8from .api import enc, paginate
9
10
11def fetch_projects(group, cfg):
12 """List all projects in the group, including subgroups."""
13 return paginate(
14 f"{cfg['base_url']}/groups/{enc(group)}/projects",
15 cfg,
16 {"include_subgroups": "true", "archived": "false"},
17 )
18
19
20def project_list(group, cfg, projects):
21 """Format the project cache into audit rows."""
22 return [
23 {
24 "id": p["id"],
25 "name": p["name"],
26 "path": p.get("path_with_namespace", ""),
27 "visibility": p.get("visibility", ""),
28 "default_branch": p.get("default_branch", ""),
29 "archived": p.get("archived", False),
30 "web_url": p.get("web_url", ""),
31 }
32 for p in projects
33 ]
applications/gitlab/collectors/settings.py added +38
@@ -0,0 +1,38 @@
1"""
2Collect the instance password policy from application settings.
3
4Requires an admin token on a self-hosted instance; not available on
5GitLab.com. Returns an empty list with a warning on 403/404.
6"""
7
8import sys
9
10import requests
11
12PASSWORD_FIELDS = [
13 "minimum_password_length",
14 "password_number_required",
15 "password_symbol_required",
16 "password_uppercase_required",
17 "password_lowercase_required",
18]
19
20
21def password_policy(group, cfg):
22 """group is unused; application settings are instance-wide."""
23 url = f"{cfg['base_url']}/application/settings"
24 try:
25 resp = requests.get(url, headers=cfg["headers"], timeout=cfg["timeout"])
26 resp.raise_for_status()
27 except requests.HTTPError as e:
28 if e.response is not None and e.response.status_code in (403, 404):
29 print(
30 "Warning: application settings require an admin token on a "
31 "self-hosted instance -- skipping.",
32 file=sys.stderr,
33 )
34 return []
35 raise
36
37 settings = resp.json()
38 return [{field: settings.get(field, "Not set") for field in PASSWORD_FIELDS}]
applications/gitlab/config.py added +45
@@ -0,0 +1,45 @@
1"""
2Configuration loader for the GitLab audit tool.
3
4Reads GITLAB_TOKEN, GITLAB_GROUP, and (optionally) GITLAB_URL from the
5environment.
6
7Usage:
8 export GITLAB_TOKEN=your_token
9 export GITLAB_GROUP=your_group_id_or_path
10 export GITLAB_URL=https://gitlab.example.com/api/v4 # self-hosted only
11"""
12
13import os
14import sys
15
16from collectors.api import DEFAULT_BASE_URL
17
18
19def load(group_override=None, base_url_override=None):
20 """Return a config dict. Exits with an error if required values are missing."""
21 token = os.environ.get("GITLAB_TOKEN", "").strip()
22 group = group_override or os.environ.get("GITLAB_GROUP", "").strip()
23 base_url = (
24 base_url_override
25 or os.environ.get("GITLAB_URL", "").strip()
26 or DEFAULT_BASE_URL
27 )
28
29 missing = []
30 if not token:
31 missing.append("GITLAB_TOKEN")
32 if not group:
33 missing.append("GITLAB_GROUP (or pass --group)")
34
35 if missing:
36 print(f"Error: missing required values: {', '.join(missing)}", file=sys.stderr)
37 sys.exit(1)
38
39 return {
40 "token": token,
41 "group": group,
42 "base_url": base_url.rstrip("/"),
43 "headers": {"PRIVATE-TOKEN": token},
44 "timeout": 30,
45 }
applications/gitlab/passwords.py deleted −39
@@ -1,39 +0,0 @@
1"""
2Verify if password policies are enforced in a self-hosted GitLab instance.
3
4 Ref: https://docs.gitlab.com/api/settings/
5"""
6
7import requests
8
9BASE_URL = "https://gitlab.com/api/v4"
10PRIVATE_TOKEN = "your_access_token"
11TIMEOUT = 30
12
13URL = f"{BASE_URL}/application/settings"
14HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
15
16if __name__ == "__main__":
17 # Get application settings
18 response = requests.get(URL, headers=HEADERS, timeout=TIMEOUT)
19 if response.status_code == 200:
20 settings = response.json()
21 minimum_password_length = settings.get("minimum_password_length", "Not set")
22 password_number_required = settings.get("password_number_required", "Not set")
23 password_symbol_required = settings.get("password_symbol_required", "Not set")
24 password_uppercase_required = settings.get(
25 "password_uppercase_required", "Not set"
26 )
27 password_lowercase_required = settings.get(
28 "password_lowercase_required", "Not set"
29 )
30
31 print(f"Password Length: {minimum_password_length}")
32 print(f"Password Number Required: {password_number_required}")
33 print(f"Password Symbol Required: {password_symbol_required}")
34 print(f"Password Uppercase Required: {password_uppercase_required}")
35 print(f"Password Lowercase Required: {password_lowercase_required}")
36 else:
37 print(
38 f"Failed to fetch application settings: {response.status_code}, {response.text}"
39 )
applications/gitlab/pipelines.py deleted −59
@@ -1,59 +0,0 @@
1"""
2Review CI/CD pipelines and their configurations for a specific GitLab project.
3"""
4
5import requests
6
7BASE_URL = "https://gitlab.com/api/v4"
8PRIVATE_TOKEN = "your_access_token"
9PROJECT_ID = "project_id"
10TIMEOUT = 30
11
12HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
13
14if __name__ == "__main__":
15 page = 1
16 per_page = 100
17
18 while True:
19 response = requests.get(
20 f"{BASE_URL}/projects/{PROJECT_ID}/pipelines",
21 headers=HEADERS,
22 params={"page": page, "per_page": per_page},
23 timeout=TIMEOUT,
24 )
25 if response.status_code == 200:
26 pipelines = response.json()
27 if not pipelines:
28 break
29
30 for pipeline in pipelines:
31 pipeline_id = pipeline["id"]
32 status = pipeline["status"]
33 ref = pipeline["ref"]
34 created_at = pipeline["created_at"]
35 duration = pipeline.get("duration", "N/A")
36
37 print(f"Pipeline ID: {pipeline_id}")
38 print(f" Status: {status}")
39 print(f" Ref: {ref}")
40 print(f" Created At: {created_at}")
41 print(f" Duration: {duration} seconds")
42
43 detail_response = requests.get(
44 f"{BASE_URL}/projects/{PROJECT_ID}/pipelines/{pipeline_id}",
45 headers=HEADERS,
46 timeout=TIMEOUT,
47 )
48 if detail_response.status_code == 200:
49 pipeline_details = detail_response.json()
50 print(f" Configuration: {pipeline_details.get('config', 'N/A')}")
51 else:
52 print(
53 f" Failed to fetch pipeline details: {detail_response.status_code}, {detail_response.text}"
54 )
55
56 page += 1
57 else:
58 print(f"Failed to fetch pipelines: {response.status_code}, {response.text}")
59 break
applications/gitlab/provisioning.py deleted −32
@@ -1,32 +0,0 @@
1"""
2Track user creation and deletion events in GitLab with timestamps.
3"""
4
5import requests
6
7BASE_URL = "https://gitlab.com/api/v4"
8PRIVATE_TOKEN = "your_access_token"
9GROUP_ID = "your_group_id"
10TIMEOUT = 30
11
12URL = f"{BASE_URL}/groups/{GROUP_ID}/audit_events"
13HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
14
15if __name__ == "__main__":
16 # Get audit events
17 response = requests.get(URL, headers=HEADERS, timeout=TIMEOUT)
18 if response.status_code == 200:
19 audit_events = response.json()
20 for event in audit_events:
21 if event["entity_type"] == "User" or event["entity_type"] == "Group":
22 action = event["event_name"]
23 member_id = event["details"].get("member_id")
24 created_at = event["created_at"]
25 author = event["author_id"]
26 if action in ["member_created", "member_destroyed", "member_updated"]:
27 print(
28 f"Group: {GROUP_ID}\n",
29 f" {created_at} : Action: {action}, Member: {member_id}, Author: {author}",
30 )
31 else:
32 print(f"Failed to fetch audit events: {response.status_code}, {response.text}")
applications/gitlab/reporters/__init__.py added
applications/gitlab/reporters/csv_reporter.py added +46
@@ -0,0 +1,46 @@
1"""CSV reporter: writes one CSV file per data section into an output directory."""
2
3import csv
4import os
5
6
7def write(output_dir, filename, rows):
8 """
9 Write a list of dicts to a CSV file in output_dir.
10 Skips writing if rows is empty, but logs the skip.
11 """
12 if not rows:
13 print(f" {filename}: no data, skipping")
14 return
15
16 os.makedirs(output_dir, exist_ok=True)
17 path = os.path.join(output_dir, filename)
18
19 with open(path, "w", newline="", encoding="utf-8") as f:
20 writer = csv.DictWriter(f, fieldnames=rows[0].keys())
21 writer.writeheader()
22 writer.writerows(rows)
23
24 print(f" {filename}: {len(rows)} rows -> {path}")
25
26
27def write_summary(output_dir, group, sections):
28 """
29 Write a plain-text summary file listing section names and row counts.
30 sections: list of (label, row_count) tuples
31 """
32 path = os.path.join(output_dir, "summary.txt")
33 lines = [
34 "GitLab Audit Package",
35 f"Group: {group}",
36 "",
37 "Section Rows",
38 f"{'─' * 40}",
39 ]
40 for label, count in sections:
41 lines.append(f"{label:<35}{count}")
42
43 with open(path, "w", encoding="utf-8") as f:
44 f.write("\n".join(lines) + "\n")
45
46 print(f" summary.txt -> {path}")
applications/gitlab/repositories.py deleted −51
@@ -1,51 +0,0 @@
1"""
2List all repositories (projects) for a user or organization in GitLab.
3"""
4
5import requests
6
7BASE_URL = "https://gitlab.com/api/v4"
8PRIVATE_TOKEN = "your_access_token"
9USER_ID = "your_user_or_group_id"
10TIMEOUT = 30
11
12URL = f"{BASE_URL}/groups/{USER_ID}/projects" # Group URL
13# URL = f"{BASE_URL}/users/{USER_ID}/projects" # User URL
14HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
15
16
17def list_projects(user_or_group_id):
18 PER_PAGE = 100
19 page = 1
20 projects = []
21
22 while True:
23 response = requests.get(
24 URL,
25 headers=HEADERS,
26 timeout=TIMEOUT,
27 params={"page": page, "per_page": PER_PAGE},
28 )
29
30 if response.status_code == 200:
31 current_projects = response.json()
32 if not current_projects:
33 break
34 projects.extend(current_projects)
35 page += 1
36 else:
37 print(
38 f"Failed to retrieve projects: {response.status_code} - {response.text}"
39 )
40 break
41
42 if projects:
43 print(f"Projects under ID: {user_or_group_id}:")
44 for project in projects:
45 print(f"- {project['name']} (ID: {project['id']})")
46 else:
47 print(f"No projects found for ID: {user_or_group_id}.")
48
49
50if __name__ == "__main__":
51 list_projects(USER_ID)
applications/gitlab/users.py deleted −53
@@ -1,53 +0,0 @@
1"""
2Gather all members of specified GitLab groups and projects and their access levels.
3
4 Ref: https://docs.gitlab.com/api/members/
5"""
6
7import requests
8
9BASE_URL = "https://gitlab.com/api/v4"
10PRIVATE_TOKEN = "your_access_token"
11GROUP_IDS = ["group_id_1", "group_id_2"] # Add your group IDs here
12PROJECT_IDS = ["project_id_1", "project_id_2"] # Add your project IDs here
13TIMEOUT = 30
14
15HEADERS = {"PRIVATE-TOKEN": PRIVATE_TOKEN}
16
17
18def get_members(url, name):
19 response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
20 if response.status_code == 200:
21 members = response.json()
22 print(f"\n{name} Members:")
23 for member in members:
24 print(
25 f"Username: {member['username']}, Access Level: {member['access_level']}"
26 )
27 else:
28 print(
29 f"Failed to fetch members for {name}: {response.status_code}, {response.text}"
30 )
31
32
33if __name__ == "__main__":
34 access_levels = """Access Level Roles:
35 0 : No access
36 5 : Minimal access
37 10 : Guest
38 15 : Planner
39 20 : Reporter
40 30 : Developer
41 40 : Maintainer
42 50 : Owner
43 60 : Admin
44 """
45 print(access_levels)
46
47 for group_id in GROUP_IDS:
48 group_url = f"{BASE_URL}/groups/{group_id}/members"
49 get_members(group_url, f"Group {group_id}")
50
51 for project_id in PROJECT_IDS:
52 project_url = f"{BASE_URL}/projects/{project_id}/members"
53 get_members(project_url, f"Project {project_id}")
audit_tui.py added +7
@@ -0,0 +1,7 @@
1#!/usr/bin/env python3
2"""Launch the Audit Tools interactive terminal UI."""
3
4from tui.app import main
5
6if __name__ == "__main__":
7 main()
conftest.py added +10
@@ -0,0 +1,10 @@
1"""Ensure the repository root is importable so tests can use absolute imports
2(``from tui import ...``, ``from sampling.sampling_tool import ...``) regardless
3of how pytest is invoked."""
4
5import os
6import sys
7
8ROOT = os.path.dirname(os.path.abspath(__file__))
9if ROOT not in sys.path:
10 sys.path.insert(0, ROOT)
requirements.txt +1
@@ -4,6 +4,7 @@ xlrd
44PyYAML
55pytest
66requests
7textual
78dash
89plotly
910urllib3>=2.7.0
tui/README.md added +63
@@ -0,0 +1,63 @@
1# Audit Tools — Interactive TUI
2
3A terminal UI that walks you through running an audit. It presents a platform
4menu, collects connection details and check selection, then runs the existing
5collectors with live progress.
6
7GitHub and GitLab are supported. Adding a platform is a matter of writing a
8runner and a `Platform` descriptor in `tui/platforms.py` — the screens are
9platform-agnostic.
10
11## Run it
12
13```bash
14pip install -r requirements.txt
15python audit_tui.py
16```
17
18The connection fields are pre-filled from environment variables if set:
19
20```bash
21# GitHub
22export GITHUB_ORG=my-org
23export GITHUB_TOKEN=ghp_... # needs read:org and repo scopes
24
25# GitLab
26export GITLAB_GROUP=my-group
27export GITLAB_TOKEN=glpat-... # needs read_api scope
28export GITLAB_URL=https://gitlab.example.com/api/v4 # self-hosted only
29```
30
31## Walkthrough
32
331. **Platform** — choose GitHub or GitLab.
342. **Connection** — the audit subject (org / group), a masked token, and any
35 platform-specific fields (branch for GitHub; API base URL for GitLab).
363. **Checks** — toggle which checks to run. Plan-restricted checks (GitHub's
37 Enterprise audit log; GitLab's Premium and self-hosted checks) are off by
38 default.
394. **Run** — a progress bar and live log show each check completing with its row
40 count. Errors on a single check are reported without stopping the run.
41
42## Output
43
44The TUI writes the same package the platform's `audit.py` produces:
45`<output>/github_audit_<org>_<date>/` or `<output>/gitlab_audit_<group>_<date>/`,
46one CSV per check plus a `summary.txt`. It reuses each platform's collectors and
47CSV reporter unchanged — the TUI is only an interactive driver around them.
48
49## Keys
50
51- `Esc` — back / return to menu
52- `Ctrl+P` — command palette
53- `q` — quit (from the menu)
54
55## Tests
56
57```bash
58python -m pytest tui/tests
59```
60
61The tests stub the network-bound collectors, so they run offline: one suite
62covers the run orchestration, another drives the app headlessly through every
63screen.
tui/__init__.py added +1
@@ -0,0 +1 @@
1"""Interactive terminal UI for running audit collectors."""
tui/app.py added +337
@@ -0,0 +1,337 @@
1"""
2Audit Tools — interactive terminal UI.
3
4Presents a platform menu, walks the user through credentials and check
5selection, then runs the selected platform's collectors with live progress.
6
7Run it with:
8
9 python audit_tui.py
10"""
11
12from typing import ClassVar
13
14from rich.text import Text
15from textual import work
16from textual.app import App, ComposeResult
17from textual.containers import Center, Horizontal, Vertical
18from textual.screen import Screen
19from textual.widgets import (
20 Button,
21 Footer,
22 Header,
23 Input,
24 Label,
25 ProgressBar,
26 RichLog,
27 SelectionList,
28 Static,
29)
30from textual.widgets.selection_list import Selection
31
32from tui import platforms
33from tui.common import Check, ProgressEvent
34
35
36class MenuScreen(Screen):
37 """Pick a platform to audit."""
38
39 BINDINGS: ClassVar[list] = [("q", "app.quit", "Quit")]
40
41 def compose(self) -> ComposeResult:
42 yield Header()
43 with Center(), Vertical(id="menu-box"):
44 yield Static("Select a platform to audit", classes="prompt")
45 for platform in platforms.PLATFORMS:
46 label = platform.label
47 if not platform.enabled:
48 label = f"{label} — coming soon"
49 yield Button(
50 label,
51 id=platform.key,
52 variant="primary" if platform.enabled else "default",
53 disabled=not platform.enabled,
54 )
55 yield Footer()
56
57 def on_mount(self) -> None:
58 self.sub_title = "Select a platform"
59
60 def on_button_pressed(self, event: Button.Pressed) -> None:
61 for platform in platforms.PLATFORMS:
62 if event.button.id == platform.key and platform.enabled:
63 self.app.platform = platform
64 self.app.push_screen(ConfigScreen())
65 return
66
67
68class ConfigScreen(Screen):
69 """Collect the connection details for the chosen platform."""
70
71 BINDINGS: ClassVar[list] = [("escape", "back", "Back")]
72
73 def compose(self) -> ComposeResult:
74 platform = self.app.platform
75 yield Header()
76 with Center(), Vertical(id="form-box"):
77 yield Static(
78 f"{platform.label} audit — connection details", classes="prompt"
79 )
80 for f in platform.fields:
81 yield Label(f.label)
82 yield Input(
83 value=platforms.prefill(f),
84 placeholder=f.placeholder,
85 password=f.password,
86 id=f.key,
87 )
88 yield Static("", id="form-error", classes="error")
89 with Horizontal(classes="buttons"):
90 yield Button("Back", id="back")
91 yield Button("Continue", id="continue", variant="primary")
92 yield Footer()
93
94 def on_mount(self) -> None:
95 platform = self.app.platform
96 self.sub_title = f"{platform.label} · connection"
97 self.query_one(f"#{platform.fields[0].key}", Input).focus()
98
99 def action_back(self) -> None:
100 self.app.pop_screen()
101
102 def on_button_pressed(self, event: Button.Pressed) -> None:
103 if event.button.id == "back":
104 self.app.pop_screen()
105 elif event.button.id == "continue":
106 self._submit()
107
108 def on_input_submitted(self, event: Input.Submitted) -> None:
109 self._submit()
110
111 def _submit(self) -> None:
112 platform = self.app.platform
113 settings = {}
114 missing = []
115 for f in platform.fields:
116 value = self.query_one(f"#{f.key}", Input).value.strip()
117 if not value:
118 value = f.default
119 if f.required and not value:
120 missing.append(f.label.lower())
121 settings[f.key] = value
122
123 if missing:
124 self.query_one("#form-error", Static).update(
125 f"Please provide: {', '.join(missing)}."
126 )
127 return
128
129 self.app.settings = settings
130 self.app.push_screen(ChecksScreen())
131
132
133class ChecksScreen(Screen):
134 """Choose which checks to run."""
135
136 BINDINGS: ClassVar[list] = [("escape", "back", "Back")]
137
138 def compose(self) -> ComposeResult:
139 platform = self.app.platform
140 yield Header()
141 with Center(), Vertical(id="checks-box"):
142 yield Static("Select checks to run", classes="prompt")
143 yield SelectionList(
144 *[
145 Selection(
146 self._prompt(c),
147 c.key,
148 c.key in platform.default_selection,
149 )
150 for c in platform.checks
151 ],
152 id="checks",
153 )
154 yield Static("", id="checks-error", classes="error")
155 with Horizontal(classes="buttons"):
156 yield Button("Back", id="back")
157 yield Button("Run audit", id="run", variant="primary")
158 yield Footer()
159
160 def on_mount(self) -> None:
161 self.sub_title = f"{self.app.platform.label} · select checks"
162 self.query_one("#checks", SelectionList).focus()
163
164 @staticmethod
165 def _prompt(check: Check) -> Text:
166 text = Text(check.label)
167 if check.note:
168 text.append(f" ({check.note})", style="dim italic")
169 return text
170
171 def action_back(self) -> None:
172 self.app.pop_screen()
173
174 def on_button_pressed(self, event: Button.Pressed) -> None:
175 if event.button.id == "back":
176 self.app.pop_screen()
177 elif event.button.id == "run":
178 selected = list(self.query_one("#checks", SelectionList).selected)
179 if not selected:
180 self.query_one("#checks-error", Static).update(
181 "Select at least one check."
182 )
183 return
184 self.app.selected_keys = selected
185 self.app.push_screen(RunScreen())
186
187
188class RunScreen(Screen):
189 """Run the selected checks with live progress."""
190
191 BINDINGS: ClassVar[list] = [("escape", "home", "Menu")]
192
193 def compose(self) -> ComposeResult:
194 yield Header()
195 with Vertical(id="run-box"):
196 yield Static(id="run-target", classes="prompt")
197 yield ProgressBar(id="progress", show_eta=False)
198 yield RichLog(id="log", markup=True, highlight=False, wrap=True)
199 with Horizontal(classes="buttons"):
200 yield Button("Back to menu", id="menu", disabled=True)
201 yield Button("Quit", id="quit", disabled=True, variant="primary")
202 yield Footer()
203
204 def on_mount(self) -> None:
205 platform = self.app.platform
206 settings = self.app.settings
207 keys = self.app.selected_keys
208 self.sub_title = f"{platform.label} · running"
209 self.output_dir = platform.output_dir(settings)
210 target = settings[platform.id_key]
211 self.query_one("#run-target", Static).update(
212 f"Auditing [b]{target}[/] · {len(keys)} checks · → {self.output_dir}"
213 )
214 self.query_one("#progress", ProgressBar).update(total=len(keys), progress=0)
215 self.run_audit()
216
217 @work(thread=True)
218 def run_audit(self) -> None:
219 platform = self.app.platform
220 settings = self.app.settings
221 keys = self.app.selected_keys
222 try:
223 platform.run(
224 settings,
225 self.output_dir,
226 keys,
227 lambda ev: self.app.call_from_thread(self._handle_event, ev),
228 )
229 except Exception as e: # noqa: BLE001 - report unexpected failures in the UI
230 self.app.call_from_thread(self._log, f"[red]Run failed:[/] {e}")
231 finally:
232 self.app.call_from_thread(self._finish)
233
234 def _log(self, markup: str) -> None:
235 self.query_one("#log", RichLog).write(markup)
236
237 def _handle_event(self, ev: ProgressEvent) -> None:
238 if ev.kind == "fetch":
239 self._log(f"[dim]· {ev.label}…[/]")
240 elif ev.kind == "start":
241 self._log(f"[cyan]▶[/] {ev.label}…")
242 elif ev.kind == "done":
243 self._log(f"[green]✓[/] {ev.label} — [b]{ev.count}[/] rows")
244 self.query_one("#progress", ProgressBar).advance(1)
245 elif ev.kind == "error":
246 self._log(f"[red]✗[/] {ev.label} — {ev.message}")
247 self.query_one("#progress", ProgressBar).advance(1)
248 elif ev.kind == "summary":
249 self._log("")
250 self._log(f"[bold green]Done.[/] Package written to {ev.label}")
251
252 def _finish(self) -> None:
253 self.query_one("#menu", Button).disabled = False
254 self.query_one("#quit", Button).disabled = False
255
256 def action_home(self) -> None:
257 self.app.show_menu()
258
259 def on_button_pressed(self, event: Button.Pressed) -> None:
260 if event.button.id == "menu":
261 self.app.show_menu()
262 elif event.button.id == "quit":
263 self.app.exit()
264
265
266class AuditApp(App):
267 TITLE = "Audit Tools"
268
269 CSS = """
270 Screen {
271 align: center middle;
272 }
273 #menu-box, #form-box, #checks-box {
274 width: 64;
275 height: auto;
276 padding: 1 2;
277 border: round $primary;
278 }
279 #run-box {
280 width: 90%;
281 height: 90%;
282 padding: 1 2;
283 border: round $primary;
284 }
285 .prompt {
286 text-style: bold;
287 margin-bottom: 1;
288 }
289 .error {
290 color: $error;
291 margin-top: 1;
292 }
293 Label {
294 margin-top: 1;
295 }
296 .buttons {
297 height: auto;
298 margin-top: 1;
299 align-horizontal: right;
300 }
301 .buttons Button {
302 margin-left: 2;
303 }
304 #menu-box Button {
305 width: 100%;
306 margin-top: 1;
307 }
308 #checks {
309 height: auto;
310 max-height: 14;
311 }
312 #log {
313 height: 1fr;
314 border: round $panel;
315 padding: 0 1;
316 margin-top: 1;
317 }
318 """
319
320 def on_mount(self) -> None:
321 self.platform = None
322 self.settings: dict = {}
323 self.selected_keys: list = []
324 self.push_screen(MenuScreen())
325
326 def show_menu(self) -> None:
327 """Pop back to the platform menu."""
328 while len(self.screen_stack) > 2:
329 self.pop_screen()
330
331
332def main() -> None:
333 AuditApp().run()
334
335
336if __name__ == "__main__":
337 main()
tui/common.py added +30
@@ -0,0 +1,30 @@
1"""Shared types used by the platform runners and the TUI."""
2
3from collections.abc import Callable
4from dataclasses import dataclass
5
6
7# ``arg`` describes how a collector is called:
8# "base" -> fn(target, cfg)
9# "collabs" -> fn(target, cfg, repo_collabs) (GitHub collaborator cache)
10# "projects" -> fn(target, cfg, projects) (GitLab project cache)
11@dataclass(frozen=True)
12class Check:
13 key: str
14 label: str
15 fn: Callable
16 filename: str
17 arg: str = "base"
18 note: str = ""
19
20
21# kind is one of: "fetch", "start", "done", "error", "summary".
22@dataclass(frozen=True)
23class ProgressEvent:
24 kind: str
25 label: str
26 count: int | None = None
27 message: str = ""
28
29
30ProgressCallback = Callable[[ProgressEvent], None]
tui/github_runner.py added +180
@@ -0,0 +1,180 @@
1"""
2Drive the existing GitHub audit collectors from the TUI.
3
4This module reuses the collectors and CSV reporter under
5``applications/github`` unchanged. It exposes:
6
7- ``CHECKS``: the list of available audit checks the UI presents.
8- ``run_audit``: run the selected checks, write the same output package
9 ``audit.py`` produces, and report progress through a callback.
10"""
11
12import os
13import sys
14from collections.abc import Iterable
15from datetime import date
16
17from tui.common import Check, ProgressCallback, ProgressEvent
18
19# Import the GitHub collectors as a namespaced package so the GitHub and GitLab
20# collector packages (both named ``collectors`` on disk) can coexist in one
21# process. Requires the repo root on sys.path.
22_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23if _REPO_ROOT not in sys.path:
24 sys.path.insert(0, _REPO_ROOT)
25
26from applications.github.collectors import (
27 audit_log,
28 branch_protections,
29 commits,
30 members,
31)
32from applications.github.reporters import csv_reporter
33
34# --- Check registry ---------------------------------------------------------
35
36# For GitHub, ``arg`` is "base" -> fn(org, cfg), "collabs" -> fn(org, cfg,
37# repo_collabs), or "branch" -> fn(org, cfg, branch).
38
39CHECKS: list[Check] = [
40 Check("member_roster", "Member roster", members.member_roster, "member_roster.csv"),
41 Check(
42 "two_factor",
43 "2FA disabled",
44 members.two_factor_disabled,
45 "two_factor_disabled.csv",
46 note="requires org owner token",
47 ),
48 Check(
49 "outside_collaborators",
50 "Outside collaborators",
51 members.outside_collaborators,
52 "outside_collaborators.csv",
53 arg="collabs",
54 ),
55 Check(
56 "privileged_access",
57 "Privileged access",
58 members.privileged_access,
59 "privileged_access.csv",
60 arg="collabs",
61 ),
62 Check(
63 "pending_invitations",
64 "Pending invitations",
65 members.pending_invitations,
66 "pending_invitations.csv",
67 ),
68 Check(
69 "team_permissions",
70 "Team permissions",
71 members.team_permissions,
72 "team_permissions.csv",
73 ),
74 Check(
75 "permission_matrix",
76 "Permission matrix",
77 members.permission_matrix,
78 "permission_matrix.csv",
79 arg="collabs",
80 ),
81 Check(
82 "branch_protections",
83 "Branch protections",
84 branch_protections.branch_protections,
85 "branch_protections.csv",
86 ),
87 Check("commits", "Commits", commits.commits, "commits.csv", arg="branch"),
88 Check(
89 "audit_log",
90 "Audit log (branch/ruleset changes)",
91 audit_log.audit_log,
92 "audit_log.csv",
93 note="requires GitHub Enterprise Cloud",
94 ),
95]
96
97DEFAULT_SELECTION = [c.key for c in CHECKS if c.key != "audit_log"]
98
99
100# --- Config + output helpers ------------------------------------------------
101
102
103def build_cfg(token: str) -> dict:
104 """Build the config dict the collectors expect (mirrors config.load())."""
105 return {
106 "token": token,
107 "headers": {
108 "Authorization": f"token {token}",
109 "Accept": "application/vnd.github.v3+json",
110 },
111 "timeout": 30,
112 }
113
114
115def default_output_dir(out: str, org: str) -> str:
116 """Match the folder naming used by audit.py."""
117 return os.path.join(out, f"github_audit_{org}_{date.today().isoformat()}")
118
119
120# --- Runner -----------------------------------------------------------------
121
122
123def run_audit(
124 *,
125 org: str,
126 token: str,
127 output_dir: str,
128 branch: str,
129 selected_keys: Iterable[str],
130 on_event: ProgressCallback,
131) -> list[tuple[str, int]]:
132 """
133 Run the selected checks and write the audit package to ``output_dir``.
134
135 A collector that raises is reported as an error and recorded with a count
136 of 0, matching audit.py's behavior of never aborting the whole run.
137
138 Returns the list of (label, row_count) sections that was written to the
139 summary file.
140 """
141 cfg = build_cfg(token)
142 selected = set(selected_keys)
143 checks = [c for c in CHECKS if c.key in selected]
144
145 repo_collabs: list | None = None
146 if any(c.arg == "collabs" for c in checks):
147 on_event(ProgressEvent("fetch", "Repo collaborators (shared cache)"))
148 try:
149 repo_collabs = members.fetch_repo_collaborators(org, cfg)
150 except Exception as e: # noqa: BLE001 - surface, keep going
151 on_event(
152 ProgressEvent(
153 "error", "Repo collaborators (shared cache)", message=str(e)
154 )
155 )
156 repo_collabs = []
157
158 sections: list[tuple[str, int]] = []
159 for c in checks:
160 on_event(ProgressEvent("start", c.label))
161 try:
162 if c.arg == "collabs":
163 rows = c.fn(org, cfg, repo_collabs or [])
164 elif c.arg == "branch":
165 rows = c.fn(org, cfg, branch)
166 else:
167 rows = c.fn(org, cfg)
168 except Exception as e: # noqa: BLE001 - one bad check shouldn't kill the run
169 on_event(ProgressEvent("error", c.label, message=str(e)))
170 sections.append((c.label, 0))
171 continue
172
173 csv_reporter.write(output_dir, c.filename, rows)
174 sections.append((c.label, len(rows)))
175 on_event(ProgressEvent("done", c.label, count=len(rows)))
176
177 csv_reporter.write_summary(output_dir, org, sections)
178 total = sum(n for _, n in sections)
179 on_event(ProgressEvent("summary", output_dir, count=total))
180 return sections
tui/gitlab_runner.py added +163
@@ -0,0 +1,163 @@
1"""
2Drive the GitLab audit collectors from the TUI.
3
4Reuses the collectors and CSV reporter under ``applications/gitlab`` unchanged.
5Mirrors github_runner: a ``CHECKS`` registry plus ``run_audit`` that writes the
6same package ``applications/gitlab/audit.py`` produces and reports progress
7through a callback.
8"""
9
10import os
11import sys
12from collections.abc import Iterable
13from datetime import date
14
15from tui.common import Check, ProgressCallback, ProgressEvent
16
17# Namespaced import so the GitHub and GitLab collector packages (both named
18# ``collectors`` on disk) can coexist in one process.
19_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20if _REPO_ROOT not in sys.path:
21 sys.path.insert(0, _REPO_ROOT)
22
23from applications.gitlab.collectors import (
24 approvals,
25 audit_events,
26 branch_protections,
27 members,
28 pipelines,
29 projects,
30 settings,
31)
32from applications.gitlab.reporters import csv_reporter
33
34# --- Check registry ---------------------------------------------------------
35
36# For GitLab, ``arg`` is "base" -> fn(group, cfg) or "projects" -> fn(group,
37# cfg, projects), where the project cache is fetched once and shared.
38
39CHECKS: list[Check] = [
40 Check("group_members", "Group members", members.group_members, "group_members.csv"),
41 Check(
42 "projects", "Projects", projects.project_list, "projects.csv", arg="projects"
43 ),
44 Check(
45 "project_members",
46 "Project members",
47 members.project_members,
48 "project_members.csv",
49 arg="projects",
50 ),
51 Check(
52 "branch_protections",
53 "Branch protections",
54 branch_protections.branch_protections,
55 "branch_protections.csv",
56 arg="projects",
57 ),
58 Check(
59 "pipelines",
60 "Pipelines",
61 pipelines.pipelines,
62 "pipelines.csv",
63 arg="projects",
64 ),
65 Check(
66 "approval_rules",
67 "Approval rules",
68 approvals.approval_rules,
69 "approval_rules.csv",
70 arg="projects",
71 note="requires Premium/Ultimate",
72 ),
73 Check(
74 "audit_events",
75 "Audit events",
76 audit_events.audit_events,
77 "audit_events.csv",
78 note="requires Premium/Ultimate",
79 ),
80 Check(
81 "password_policy",
82 "Password policy",
83 settings.password_policy,
84 "password_policy.csv",
85 note="self-hosted, admin token",
86 ),
87]
88
89_PREMIUM = {"approval_rules", "audit_events", "password_policy"}
90DEFAULT_SELECTION = [c.key for c in CHECKS if c.key not in _PREMIUM]
91
92
93# --- Config + output helpers ------------------------------------------------
94
95
96def build_cfg(token: str, base_url: str) -> dict:
97 """Build the config dict the collectors expect (mirrors config.load())."""
98 return {
99 "token": token,
100 "base_url": base_url.rstrip("/"),
101 "headers": {"PRIVATE-TOKEN": token},
102 "timeout": 30,
103 }
104
105
106def default_output_dir(out: str, group: str) -> str:
107 """Match the folder naming used by applications/gitlab/audit.py."""
108 safe_group = group.replace("/", "-")
109 return os.path.join(out, f"gitlab_audit_{safe_group}_{date.today().isoformat()}")
110
111
112# --- Runner -----------------------------------------------------------------
113
114
115def run_audit(
116 *,
117 group: str,
118 token: str,
119 base_url: str,
120 output_dir: str,
121 selected_keys: Iterable[str],
122 on_event: ProgressCallback,
123) -> list[tuple[str, int]]:
124 """
125 Run the selected checks and write the audit package to ``output_dir``.
126
127 A collector that raises is reported as an error and recorded with a count
128 of 0, so one bad check never aborts the whole run.
129 """
130 cfg = build_cfg(token, base_url)
131 selected = set(selected_keys)
132 checks = [c for c in CHECKS if c.key in selected]
133
134 project_cache: list | None = None
135 if any(c.arg == "projects" for c in checks):
136 on_event(ProgressEvent("fetch", "Projects (shared cache)"))
137 try:
138 project_cache = projects.fetch_projects(group, cfg)
139 except Exception as e: # noqa: BLE001 - surface, keep going
140 on_event(ProgressEvent("error", "Projects (shared cache)", message=str(e)))
141 project_cache = []
142
143 sections: list[tuple[str, int]] = []
144 for c in checks:
145 on_event(ProgressEvent("start", c.label))
146 try:
147 if c.arg == "projects":
148 rows = c.fn(group, cfg, project_cache or [])
149 else:
150 rows = c.fn(group, cfg)
151 except Exception as e: # noqa: BLE001 - one bad check shouldn't kill the run
152 on_event(ProgressEvent("error", c.label, message=str(e)))
153 sections.append((c.label, 0))
154 continue
155
156 csv_reporter.write(output_dir, c.filename, rows)
157 sections.append((c.label, len(rows)))
158 on_event(ProgressEvent("done", c.label, count=len(rows)))
159
160 csv_reporter.write_summary(output_dir, group, sections)
161 total = sum(n for _, n in sections)
162 on_event(ProgressEvent("summary", output_dir, count=total))
163 return sections
tui/platforms.py added +144
@@ -0,0 +1,144 @@
1"""
2Platform descriptors that let the TUI drive any collector runner.
3
4Each Platform declares its connection form (``fields``), its checks, and how to
5compute the output directory and run the audit. The screens in app.py are
6written against this interface, so adding a platform is data, not new UI.
7"""
8
9import os
10from collections.abc import Callable
11from dataclasses import dataclass, field
12
13from tui import github_runner, gitlab_runner
14from tui.common import Check
15
16
17@dataclass(frozen=True)
18class Field:
19 """One input on the connection screen."""
20
21 key: str
22 label: str
23 placeholder: str = ""
24 default: str = ""
25 password: bool = False
26 required: bool = False
27 env: str | None = None # environment variable used to pre-fill the value
28
29
30@dataclass(frozen=True)
31class Platform:
32 key: str
33 label: str
34 id_key: str # which field is the audit subject (org / group)
35 fields: list[Field]
36 checks: list[Check]
37 default_selection: list[str]
38 output_dir: Callable[[dict], str] # (settings) -> path
39 run: Callable[..., object] # (settings, output_dir, selected_keys, on_event)
40 enabled: bool = True
41 note: str = field(default="")
42
43
44def _prefill(f: Field) -> str:
45 if f.env:
46 value = os.environ.get(f.env, "").strip()
47 if value:
48 return value
49 return f.default
50
51
52def _github_output_dir(s: dict) -> str:
53 return github_runner.default_output_dir(s["out"], s["org"])
54
55
56def _github_run(s: dict, output_dir, selected_keys, on_event):
57 return github_runner.run_audit(
58 org=s["org"],
59 token=s["token"],
60 output_dir=output_dir,
61 branch=s["branch"],
62 selected_keys=selected_keys,
63 on_event=on_event,
64 )
65
66
67def _gitlab_output_dir(s: dict) -> str:
68 return gitlab_runner.default_output_dir(s["out"], s["group"])
69
70
71def _gitlab_run(s: dict, output_dir, selected_keys, on_event):
72 return gitlab_runner.run_audit(
73 group=s["group"],
74 token=s["token"],
75 base_url=s["base_url"],
76 output_dir=output_dir,
77 selected_keys=selected_keys,
78 on_event=on_event,
79 )
80
81
82GITHUB = Platform(
83 key="github",
84 label="GitHub",
85 id_key="org",
86 fields=[
87 Field("org", "Organization", "my-org", required=True, env="GITHUB_ORG"),
88 Field(
89 "token",
90 "Personal access token",
91 "ghp_… (read:org, repo)",
92 password=True,
93 required=True,
94 env="GITHUB_TOKEN",
95 ),
96 Field("out", "Output directory", default="./output"),
97 Field("branch", "Branch (for commit history)", default="main"),
98 ],
99 checks=github_runner.CHECKS,
100 default_selection=github_runner.DEFAULT_SELECTION,
101 output_dir=_github_output_dir,
102 run=_github_run,
103)
104
105GITLAB = Platform(
106 key="gitlab",
107 label="GitLab",
108 id_key="group",
109 fields=[
110 Field(
111 "group",
112 "Group ID or path",
113 "e.g. 1234567 or my-group",
114 required=True,
115 env="GITLAB_GROUP",
116 ),
117 Field(
118 "token",
119 "Personal access token",
120 "glpat-… (read_api)",
121 password=True,
122 required=True,
123 env="GITLAB_TOKEN",
124 ),
125 Field(
126 "base_url",
127 "API base URL (self-hosted)",
128 default="https://gitlab.com/api/v4",
129 env="GITLAB_URL",
130 ),
131 Field("out", "Output directory", default="./output"),
132 ],
133 checks=gitlab_runner.CHECKS,
134 default_selection=gitlab_runner.DEFAULT_SELECTION,
135 output_dir=_gitlab_output_dir,
136 run=_gitlab_run,
137)
138
139PLATFORMS = [GITHUB, GITLAB]
140
141
142def prefill(f: Field) -> str:
143 """Public accessor for a field's pre-filled value (env var or default)."""
144 return _prefill(f)
tui/tests/test_app.py added +127
@@ -0,0 +1,127 @@
1"""Headless smoke tests for the Textual app.
2
3Drives the app through its screens with a Pilot, stubbing the network-bound
4run_audit so no real GitHub calls are made. Uses asyncio.run so the suite does
5not require the pytest-asyncio plugin.
6"""
7
8import asyncio
9
10from textual.widgets import Button, Input
11
12from tui import github_runner as gh
13from tui.app import AuditApp, ChecksScreen, ConfigScreen, MenuScreen, RunScreen
14
15
16def _run(coro):
17 asyncio.run(coro)
18
19
20def test_full_navigation(monkeypatch):
21 def fake_run_audit(*, org, token, output_dir, branch, selected_keys, on_event):
22 on_event(gh.ProgressEvent("start", "Member roster"))
23 on_event(gh.ProgressEvent("done", "Member roster", count=3))
24 on_event(gh.ProgressEvent("summary", output_dir, count=3))
25 return [("Member roster", 3)]
26
27 monkeypatch.setattr("tui.github_runner.run_audit", fake_run_audit)
28
29 async def scenario():
30 app = AuditApp()
31 async with app.run_test(size=(120, 40)) as pilot:
32 await pilot.pause()
33 assert isinstance(app.screen, MenuScreen)
34
35 await pilot.click("#github")
36 await pilot.pause()
37 assert isinstance(app.screen, ConfigScreen)
38
39 app.screen.query_one("#org", Input).value = "acme"
40 app.screen.query_one("#token", Input).value = "tok"
41 await pilot.click("#continue")
42 await pilot.pause()
43 assert isinstance(app.screen, ChecksScreen)
44 assert app.settings["org"] == "acme"
45
46 await pilot.click("#run")
47 await pilot.pause()
48 assert isinstance(app.screen, RunScreen)
49
50 await app.workers.wait_for_complete()
51 await pilot.pause()
52
53 # When the run finishes, the exit buttons become enabled.
54 assert app.screen.query_one("#menu", Button).disabled is False
55 assert app.screen.query_one("#quit", Button).disabled is False
56
57 _run(scenario())
58
59
60def test_config_requires_org_and_token(monkeypatch):
61 # Make sure env vars don't pre-fill the fields for this test.
62 monkeypatch.delenv("GITHUB_ORG", raising=False)
63 monkeypatch.delenv("GITHUB_TOKEN", raising=False)
64
65 async def scenario():
66 app = AuditApp()
67 async with app.run_test(size=(120, 40)) as pilot:
68 await pilot.click("#github")
69 await pilot.pause()
70 # Continue with empty fields -> stays on ConfigScreen with an error.
71 await pilot.click("#continue")
72 await pilot.pause()
73 assert isinstance(app.screen, ConfigScreen)
74 error_text = str(app.screen.query_one("#form-error").render())
75 assert "provide" in error_text.lower()
76
77 _run(scenario())
78
79
80def test_gitlab_is_enabled():
81 async def scenario():
82 app = AuditApp()
83 async with app.run_test(size=(120, 40)) as pilot:
84 await pilot.pause()
85 assert app.screen.query_one("#gitlab", Button).disabled is False
86
87 _run(scenario())
88
89
90def test_gitlab_navigation(monkeypatch):
91 monkeypatch.delenv("GITLAB_GROUP", raising=False)
92 monkeypatch.delenv("GITLAB_TOKEN", raising=False)
93
94 def fake_run_audit(*, group, token, base_url, output_dir, selected_keys, on_event):
95 on_event(gh.ProgressEvent("done", "Group members", count=7))
96 on_event(gh.ProgressEvent("summary", output_dir, count=7))
97 return [("Group members", 7)]
98
99 monkeypatch.setattr("tui.gitlab_runner.run_audit", fake_run_audit)
100
101 async def scenario():
102 app = AuditApp()
103 async with app.run_test(size=(120, 40)) as pilot:
104 await pilot.pause()
105 await pilot.click("#gitlab")
106 await pilot.pause()
107 assert isinstance(app.screen, ConfigScreen)
108
109 app.screen.query_one("#group", Input).value = "my-group"
110 app.screen.query_one("#token", Input).value = "glpat-x"
111 await pilot.click("#continue")
112 await pilot.pause()
113 assert isinstance(app.screen, ChecksScreen)
114 assert app.settings["group"] == "my-group"
115 # Self-hosted URL defaults to gitlab.com.
116 assert app.settings["base_url"] == "https://gitlab.com/api/v4"
117
118 await pilot.click("#run")
119 await pilot.pause()
120 assert isinstance(app.screen, RunScreen)
121 assert "gitlab_audit_my-group" in app.screen.output_dir
122
123 await app.workers.wait_for_complete()
124 await pilot.pause()
125 assert app.screen.query_one("#menu", Button).disabled is False
126
127 _run(scenario())
tui/tests/test_github_runner.py added +114
@@ -0,0 +1,114 @@
1"""Tests for the TUI's GitHub audit orchestration.
2
3These stub out the network-bound collectors and verify run_audit's wiring:
4argument dispatch, the shared collaborator cache, per-check error handling,
5and that the CSV package (per-check files + summary) is written.
6"""
7
8import csv
9import os
10
11import pytest
12
13from tui import github_runner as r
14
15
16@pytest.fixture
17def fake_checks(monkeypatch):
18 """Replace the real registry with stubbed collectors and record calls."""
19 calls = {}
20
21 def base_fn(org, cfg):
22 calls["base"] = (org, cfg)
23 return [{"login": "alice"}, {"login": "bob"}]
24
25 def collabs_fn(org, cfg, repo_collabs):
26 calls["collabs"] = (org, cfg, repo_collabs)
27 return [{"repo": e["repo"]} for e in repo_collabs]
28
29 def branch_fn(org, cfg, branch):
30 calls["branch"] = (org, cfg, branch)
31 return [{"branch": branch}]
32
33 def boom_fn(org, cfg):
34 raise RuntimeError("kaboom")
35
36 checks = [
37 r.Check("base", "Base", base_fn, "base.csv"),
38 r.Check("collabs", "Collabs", collabs_fn, "collabs.csv", arg="collabs"),
39 r.Check("branch", "Branch", branch_fn, "branch.csv", arg="branch"),
40 r.Check("boom", "Boom", boom_fn, "boom.csv"),
41 ]
42 monkeypatch.setattr(r, "CHECKS", checks)
43
44 fetch_calls = []
45
46 def fake_fetch(org, cfg):
47 fetch_calls.append(org)
48 return [{"repo": "repo1", "collaborators": []}]
49
50 monkeypatch.setattr(r.members, "fetch_repo_collaborators", fake_fetch)
51
52 return calls, fetch_calls
53
54
55def run(tmp_path, keys, fake_checks, branch="main"):
56 events = []
57 sections = r.run_audit(
58 org="acme",
59 token="tok",
60 output_dir=str(tmp_path),
61 branch=branch,
62 selected_keys=keys,
63 on_event=events.append,
64 )
65 return events, sections
66
67
68def test_argument_dispatch_and_files(tmp_path, fake_checks):
69 calls, _ = fake_checks
70 run(tmp_path, ["base", "collabs", "branch"], fake_checks, "dev")
71
72 # Each collector was called with the right signature.
73 assert calls["base"][0] == "acme"
74 assert calls["collabs"][2] == [{"repo": "repo1", "collaborators": []}]
75 assert calls["branch"][2] == "dev"
76
77 # CSV files were written for each check, plus the summary.
78 for name in ("base.csv", "collabs.csv", "branch.csv", "summary.txt"):
79 assert os.path.exists(tmp_path / name), name
80
81 with open(tmp_path / "base.csv", newline="") as f:
82 assert len(list(csv.DictReader(f))) == 2
83
84
85def test_collab_cache_fetched_once(tmp_path, fake_checks):
86 _, fetch_calls = fake_checks
87 run(tmp_path, ["collabs", "base"], fake_checks)
88 assert fetch_calls == ["acme"] # fetched exactly once
89
90
91def test_collab_cache_skipped_when_not_needed(tmp_path, fake_checks):
92 _, fetch_calls = fake_checks
93 run(tmp_path, ["base"], fake_checks)
94 assert fetch_calls == [] # no collabs check selected -> no fetch
95
96
97def test_failing_check_does_not_abort_run(tmp_path, fake_checks):
98 events, sections = run(tmp_path, ["boom", "base"], fake_checks)
99
100 kinds = [(e.kind, e.label) for e in events]
101 assert ("error", "Boom") in kinds
102 assert ("done", "Base") in kinds # base still ran after boom failed
103
104 labels = dict(sections)
105 assert labels["Boom"] == 0
106 assert labels["Base"] == 2
107
108
109def test_summary_event_totals_rows(tmp_path, fake_checks):
110 events, _ = run(tmp_path, ["base", "branch"], fake_checks)
111 summary = [e for e in events if e.kind == "summary"]
112 assert len(summary) == 1
113 assert summary[0].count == 3 # 2 base + 1 branch
114 assert summary[0].label == str(tmp_path)
tui/tests/test_gitlab_runner.py added +113
@@ -0,0 +1,113 @@
1"""Tests for the TUI's GitLab audit orchestration.
2
3Stub the network-bound collectors and verify run_audit's wiring: base vs
4project-scoped dispatch, the shared project cache, per-check error handling,
5and that the CSV package (per-check files + summary) is written.
6"""
7
8import csv
9import os
10
11import pytest
12
13from tui import gitlab_runner as r
14
15
16@pytest.fixture
17def fake_checks(monkeypatch):
18 calls = {}
19
20 def base_fn(group, cfg):
21 calls["base"] = (group, cfg)
22 return [{"username": "alice"}, {"username": "bob"}]
23
24 def projects_fn(group, cfg, projects):
25 calls["projects"] = (group, cfg, projects)
26 return [{"project": p["path_with_namespace"]} for p in projects]
27
28 def boom_fn(group, cfg):
29 raise RuntimeError("kaboom")
30
31 checks = [
32 r.Check("base", "Base", base_fn, "base.csv"),
33 r.Check("projects", "Projects", projects_fn, "projects.csv", arg="projects"),
34 r.Check("boom", "Boom", boom_fn, "boom.csv"),
35 ]
36 monkeypatch.setattr(r, "CHECKS", checks)
37
38 fetch_calls = []
39
40 def fake_fetch(group, cfg):
41 fetch_calls.append(group)
42 return [{"id": 1, "path_with_namespace": "grp/proj"}]
43
44 monkeypatch.setattr(r.projects, "fetch_projects", fake_fetch)
45
46 return calls, fetch_calls
47
48
49def run(tmp_path, keys, base_url="https://gitlab.com/api/v4"):
50 events = []
51 sections = r.run_audit(
52 group="grp",
53 token="tok",
54 base_url=base_url,
55 output_dir=str(tmp_path),
56 selected_keys=keys,
57 on_event=events.append,
58 )
59 return events, sections
60
61
62def test_argument_dispatch_and_files(tmp_path, fake_checks):
63 calls, _ = fake_checks
64 run(tmp_path, ["base", "projects"])
65
66 assert calls["base"][0] == "grp"
67 assert calls["projects"][2] == [{"id": 1, "path_with_namespace": "grp/proj"}]
68
69 for name in ("base.csv", "projects.csv", "summary.txt"):
70 assert os.path.exists(tmp_path / name), name
71
72 with open(tmp_path / "projects.csv", newline="") as f:
73 rows = list(csv.DictReader(f))
74 assert rows == [{"project": "grp/proj"}]
75
76
77def test_project_cache_fetched_once(tmp_path, fake_checks):
78 _, fetch_calls = fake_checks
79 run(tmp_path, ["projects", "base"])
80 assert fetch_calls == ["grp"]
81
82
83def test_project_cache_skipped_when_not_needed(tmp_path, fake_checks):
84 _, fetch_calls = fake_checks
85 run(tmp_path, ["base"])
86 assert fetch_calls == []
87
88
89def test_failing_check_does_not_abort_run(tmp_path, fake_checks):
90 events, sections = run(tmp_path, ["boom", "base"])
91
92 kinds = [(e.kind, e.label) for e in events]
93 assert ("error", "Boom") in kinds
94 assert ("done", "Base") in kinds
95
96 labels = dict(sections)
97 assert labels["Boom"] == 0
98 assert labels["Base"] == 2
99
100
101def test_base_url_reaches_cfg(tmp_path, fake_checks):
102 calls, _ = fake_checks
103 run(tmp_path, ["base"], base_url="https://gitlab.example.com/api/v4/")
104 # build_cfg strips the trailing slash.
105 assert calls["base"][1]["base_url"] == "https://gitlab.example.com/api/v4"
106 assert calls["base"][1]["headers"]["PRIVATE-TOKEN"] == "tok"
107
108
109def test_premium_checks_off_by_default():
110 assert "approval_rules" not in r.DEFAULT_SELECTION
111 assert "audit_events" not in r.DEFAULT_SELECTION
112 assert "password_policy" not in r.DEFAULT_SELECTION
113 assert "group_members" in r.DEFAULT_SELECTION