audit-labs/audit-report

Turn audit-tools evidence packages into control-mapped, auditor-ready reports.

clone: git clone https://gitbay.org/audit-labs/audit-report.git

f14a20bc6c364cb7e2758ab3ff60c1532fb995c4

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-06T07:14:26Z

feat: add GitLab ruleset

Six checks grounded in the real audit-tools GitLab schemas: force push on
protected branches, code-owner approval, approval-rule strength, public project
visibility, instance password policy, and audit-log activity. Handles GitLab's
per-branch protection model and its omission of empty CSVs (absent table reports
as not applicable, not pass). Adds a fixture package and engine tests.
 README.md                                          |   1 +
 audit_report/rulesets/gitlab.yaml                  | 101 +++++++++++++++++++++
 .../approval_rules.csv                             |   3 +
 .../gitlab_audit_acme_2026-01-01/audit_events.csv  |   2 +
 .../branch_protections.csv                         |   3 +
 .../gitlab_audit_acme_2026-01-01/group_members.csv |   3 +
 .../gitlab_audit_acme_2026-01-01/projects.csv      |   3 +
 tests/test_engine.py                               |  26 ++++++
 8 files changed, 142 insertions(+)

diff --git a/README.md b/README.md
index 951fdb6..69afc11 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ python -m audit_report ./output/aws_audit_default_2026-07-29
 | --- | --- | --- |
 | AWS | `aws_audit_*` | Console-user MFA, access-key rotation, root MFA & keys, password policy, open SSH, public S3, CloudTrail logging |
 | GitHub | `github_audit_*` | Org 2FA, base permission, secret-scanning push protection, default-branch protection, required reviews |
+| GitLab | `gitlab_audit_*` | Force-push on protected branches, code-owner approval, approval-rule strength, public projects, instance password policy, audit logging |
 
 ## Writing your own rules
 
diff --git a/audit_report/rulesets/gitlab.yaml b/audit_report/rulesets/gitlab.yaml
new file mode 100644
index 0000000..b859a62
--- /dev/null
+++ b/audit_report/rulesets/gitlab.yaml
@@ -0,0 +1,101 @@
+# Ruleset: GitLab
+#
+# Evaluated against an audit-tools GitLab evidence package
+# (gitlab_audit_<group>_<date>/). Note two GitLab-specific facts that shape
+# these rules:
+#
+#   * branch_protections lists one row *per protected branch* — there is no
+#     "protected" boolean as on GitHub. A row's existence means the branch is
+#     protected; the checks below test how strong that protection is.
+#   * audit-tools omits a CSV entirely when a collector returns no rows, so an
+#     absent table reports as "not applicable", not "pass". A rule can only
+#     speak to data that was actually collected.
+platform: gitlab
+
+rules:
+  - id: gitlab.branch.no-force-push
+    title: Protected branches disallow force push
+    table: branch_protections
+    severity: high
+    controls: [SOC2:CC8.1, ISO:A.8.32, NIST:CM-6]
+    rationale: >
+      Allowing force push to a protected branch lets history be rewritten with
+      no trace, defeating the change-management record the protection exists to
+      provide.
+    remediation: Disable "Allow force push" on protected branches.
+    check:
+      type: fail_rows_where
+      when: {column: allow_force_push, op: is_true}
+
+  - id: gitlab.branch.code-owner-approval
+    title: Protected branches require code owner approval
+    table: branch_protections
+    severity: low
+    controls: [SOC2:CC8.1, ISO:A.8.32]
+    rationale: >
+      Code owner approval routes changes to the people accountable for the
+      affected files. It is a stricter posture than a plain review requirement
+      and is not needed everywhere — hence low severity.
+    remediation: Enable "Require approval from code owners" on protected branches where ownership matters.
+    check:
+      type: fail_rows_where
+      when: {column: code_owner_approval_required, op: is_false}
+
+  - id: gitlab.approvals.require-one
+    title: Approval rules require at least one approval
+    table: approval_rules
+    severity: medium
+    controls: [SOC2:CC8.1, ISO:A.8.32, NIST:CM-6]
+    rationale: >
+      An approval rule that requires zero approvals contributes nothing to
+      segregation of duties — a change can merge with no second person signing
+      off.
+    remediation: Set the required number of approvals to at least one on merge-blocking rules.
+    check:
+      type: fail_rows_where
+      when: {column: approvals_required, op: lt, value: 1}
+
+  - id: gitlab.projects.no-public
+    title: Projects are not publicly visible
+    table: projects
+    severity: medium
+    controls: [SOC2:CC6.3, ISO:A.5.15, NIST:AC-6]
+    rationale: >
+      A public project exposes its source and history to anyone on the
+      internet. That is sometimes intended (open source) but should be a
+      deliberate, reviewed decision rather than a default.
+    remediation: Set project visibility to private or internal unless public exposure is intended and approved.
+    check:
+      type: fail_rows_where
+      when: {column: visibility, op: equals, value: public}
+
+  - id: gitlab.password-policy
+    title: Instance password policy meets baseline strength
+    table: password_policy
+    severity: medium
+    controls: [ISO:A.5.17, NIST:IA-5]
+    rationale: >
+      A weak password policy undermines every password-based control. This table
+      is only present for self-hosted instances audited with an admin token; on
+      GitLab.com it is absent and this rule reports as not applicable.
+    remediation: Require a minimum length of 12 and mandate numbers and symbols in the instance settings.
+    check:
+      type: assert_row
+      require:
+        - {column: minimum_password_length, op: gte, value: 12}
+        - {column: password_number_required, op: is_true}
+        - {column: password_symbol_required, op: is_true}
+
+  - id: gitlab.audit.logging-active
+    title: Audit event logging is producing records
+    table: audit_events
+    severity: medium
+    controls: [SOC2:CC7.2, ISO:A.8.15, NIST:AU-2]
+    rationale: >
+      The presence of audit events is direct evidence that GitLab is recording
+      security-relevant activity. Absence here means no events were collected —
+      reported as not applicable rather than as a pass or a failure.
+    remediation: Confirm audit events are enabled and retained for the group and its projects.
+    check:
+      type: require_any_row
+      when: {column: action, op: not_empty}
diff --git a/tests/fixtures/gitlab_audit_acme_2026-01-01/approval_rules.csv b/tests/fixtures/gitlab_audit_acme_2026-01-01/approval_rules.csv
new file mode 100644
index 0000000..ec07334
--- /dev/null
+++ b/tests/fixtures/gitlab_audit_acme_2026-01-01/approval_rules.csv
@@ -0,0 +1,3 @@
+project,rule,rule_type,approvals_required,protected_branches,eligible_approvers
+acme/app,Default,regular,1,main,alice
+acme/site,License-Check,regular,0,(all),(none)
diff --git a/tests/fixtures/gitlab_audit_acme_2026-01-01/audit_events.csv b/tests/fixtures/gitlab_audit_acme_2026-01-01/audit_events.csv
new file mode 100644
index 0000000..342528d
--- /dev/null
+++ b/tests/fixtures/gitlab_audit_acme_2026-01-01/audit_events.csv
@@ -0,0 +1,2 @@
+created_at,action,member_id,target,author_id,entity_type
+2026-01-01T00:00:00Z,add_user,42,bob,1,Group
diff --git a/tests/fixtures/gitlab_audit_acme_2026-01-01/branch_protections.csv b/tests/fixtures/gitlab_audit_acme_2026-01-01/branch_protections.csv
new file mode 100644
index 0000000..c7e09d5
--- /dev/null
+++ b/tests/fixtures/gitlab_audit_acme_2026-01-01/branch_protections.csv
@@ -0,0 +1,3 @@
+project,branch,push_access,merge_access,allow_force_push,code_owner_approval_required
+acme/app,main,No one,Maintainer,False,True
+acme/site,main,Maintainer,Developer,True,False
diff --git a/tests/fixtures/gitlab_audit_acme_2026-01-01/group_members.csv b/tests/fixtures/gitlab_audit_acme_2026-01-01/group_members.csv
new file mode 100644
index 0000000..e10d229
--- /dev/null
+++ b/tests/fixtures/gitlab_audit_acme_2026-01-01/group_members.csv
@@ -0,0 +1,3 @@
+username,name,access_level,role,state
+alice,Alice,50,Owner,active
+bob,Bob,30,Developer,active
diff --git a/tests/fixtures/gitlab_audit_acme_2026-01-01/projects.csv b/tests/fixtures/gitlab_audit_acme_2026-01-01/projects.csv
new file mode 100644
index 0000000..0e8cdf3
--- /dev/null
+++ b/tests/fixtures/gitlab_audit_acme_2026-01-01/projects.csv
@@ -0,0 +1,3 @@
+id,name,path,visibility,default_branch,archived,web_url
+1,app,acme/app,private,main,False,https://gitlab.com/acme/app
+2,site,acme/site,public,main,False,https://gitlab.com/acme/site
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 574104d..65e7930 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -70,6 +70,32 @@ def test_github_fixture_findings():
     assert findings["github.branch.require-reviews"].status == PASS
 
 
+def test_gitlab_fixture_findings():
+    findings = _run("gitlab_audit_acme_2026-01-01", "gitlab.yaml")
+
+    # 'site' allows force push on a protected branch.
+    force = findings["gitlab.branch.no-force-push"]
+    assert force.status == FAIL
+    assert force.evidence[0]["project"] == "acme/site"
+
+    # 'site' does not require code owner approval.
+    assert findings["gitlab.branch.code-owner-approval"].status == FAIL
+
+    # The License-Check rule requires zero approvals.
+    approvals = findings["gitlab.approvals.require-one"]
+    assert approvals.status == FAIL
+    assert approvals.evidence[0]["rule"] == "License-Check"
+
+    # 'site' is a public project.
+    assert findings["gitlab.projects.no-public"].status == FAIL
+
+    # No password_policy.csv in the package (as on GitLab.com) -> not applicable.
+    assert findings["gitlab.password-policy"].status == NOT_APPLICABLE
+
+    # An audit event with an action is present -> logging is evidenced.
+    assert findings["gitlab.audit.logging-active"].status == PASS
+
+
 def test_not_applicable_when_table_absent(tmp_path):
     (tmp_path / "aws_audit_x_2026-01-01").mkdir()
     pkg_dir = tmp_path / "aws_audit_x_2026-01-01"