An auditor's question is never "are you secure?" but "prove it". GitLab records hundreds of types of audit events: who changed a protected branch, who approved a deployment, who changed the permissions of a runner. This guide shows you how to use these mechanisms to meet SOC 2, ISO 27001 or NIS2 requirements with concrete data.
What you will learn
By the end of this guide, you will know how to:
- Browse the audit events of GitLab (project, group, instance)
- Query the audit API to extract precise events
- Set up a compliance framework to apply policies per project
- Enforce security jobs with pipeline execution policies, and recognize the deprecated compliance pipelines you will meet on older projects
- Stream the events to an external SIEM (Splunk, ELK, Datadog)
Prerequisites
This lesson assumes that you know what a protected branch, a protected CI
variable, a deployment environment and an approved merge request are,
since audit events trace exactly the changes made to those objects. It also
assumes that you can call a REST API with curl and filter JSON with jq.
Your permissions determine what you will see: on a project, you need the
Maintainer role to read audit events (a Developer gets a 403 Forbidden),
and the Owner role on the group for group-level events, as the audit levels
and the troubleshooting table below say. To create a compliance framework or a
streaming destination, you also need the rights to open the group's Settings >
General, and a Premium (the log shown in the interface) or Ultimate (frameworks,
policies, streaming) edition. As for tools, you need curl and jq, an
API token exported as GITLAB_TOKEN, and GITLAB_URL pointing to your
instance. Upstream lessons:
- Protected branches: the protection changes that the audit records
- Managing secrets: the CI variables whose changes are traced
- Environments: deployments and production protection
- Supply chain CI: the preceding lesson, on integrity proofs for artifacts
In which context?
Audit and compliance become necessary when:
- An external auditor (SOC 2, ISO 27001) asks for proof that deployments follow a validation process
- Your organization is subject to NIS2 and must demonstrate the traceability of its software supply chain
- A security incident occurs and you must reconstruct who did what, when
- You manage dozens of projects and must guarantee that each one applies the same security rules
- Developers bypass controls (disabling a protected branch, deleting an approval) and you must detect it
Audit events: what GitLab records
An audit event is an immutable trace written by GitLab at every sensitive action: nobody can modify or delete it from the interface, which makes it admissible evidence. These events are not about code but about configuration and permissions: who opened a protected branch, who changed a secret, who added a member. The visible scope depends on your role and on the level at which the event was recorded.
The three audit levels
GitLab records events at three levels:
| Level | Visibility | Example events |
|---|---|---|
| Project | Maintainer+ | MR merged, pipeline started, variable changed, protected branch changed |
| Group | Owner | Member added/removed, permission changed, shared runner configured |
| Instance | Admin | User created, 2FA disabled, global setting changed |
Browse the audit events
Two access paths exist, and they do not return the same thing: the interface offers a filterable log but reserved for GitLab Premium, while the REST API remains available more widely and lends itself to automation. The created_after parameter expects a date in YYYY-MM-DD format; without it, the API returns only the most recent events.
Project: Settings → General → Audit events (GitLab Premium+).
You see a chronological log with the author, the action and the target.
# Audit events of a project (last 30 days)curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/audit_events?created_after=2026-03-16"Result:
[ { "id": 1234, "author_id": 42, "entity_id": 7, "entity_type": "Project", "details": { "change": "protected_branch", "from": "", "to": "main", "author_name": "Alice Martin", "target_details": "main", "custom_message": "Protected branch main: allowed to push changed from Developers + Maintainers to No one" }, "created_at": "2026-03-20T14:32:00Z" }]Critical events to monitor
GitLab records several hundred types of events, most of which interest nobody. The nine lines below are the ones that signal a weakening of controls: they almost always precede a bypass or an incident. They are the first to wire into an alert, before dealing with the rest.
| Event | Why monitor it | Category |
|---|---|---|
protected_branch_created / removed | Someone changed the branch policy | Access control |
approval_rule_created / deleted | The MR approval rules changed | Validation process |
environment_protected / environment_unprotected | The production environment lost its protection | Deployment |
variable_created / updated / deleted | A CI secret was modified | Secrets |
runner_registration | A new runner was registered | Infrastructure |
member_added / removed | Project access permissions changed | Access control |
deploy_key_added | A deploy key was added | External access |
merge_request_approved | An MR was approved (by whom?) | Traceability |
pipeline_schedule_created | A scheduled pipeline was created | Automation |
Query the audit API
The interface shows a continuous log; the API lets you answer a precise, dated and bounded question. All the queries below combine a GitLab endpoint and a jq filter, the command-line tool that slices JSON. They assume two environment variables defined beforehand: GITLAB_URL and GITLAB_TOKEN.
Typical auditor questions
Auditors ask precise questions. Here is how to answer them with the API:
"Who changed the protected branches this month?"
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/audit_events?created_after=2026-03-01" \ | jq '[.[] | select(.details.change == "protected_branch")] | .[] | {date: .created_at, author: .details.author_name, action: .details.custom_message}'"Who approved the MRs deployed to production?"
# List the MRs merged on main this monthcurl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests?state=merged&target_branch=main&updated_after=2026-03-01" \ | jq '.[] | {iid: .iid, title: .title, merged_by: .merged_by.name, approved_by: [.approved_by[].user.name]}'"Were there any deployments during the freeze?"
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/deployments?environment=production&updated_after=2026-03-28&updated_before=2026-04-02" \ | jq '.[] | {id: .id, created_at: .created_at, status: .status, deployed_by: .deployable.user.name}'Automated audit script
To generate a regular audit report:
#!/usr/bin/env bash# audit-report.sh: monthly audit report for a GitLab projectset -euo pipefail
PROJECT_ID="${1:?Usage: $0 <project_id>}"SINCE=$(date -d "30 days ago" +%Y-%m-%d)API="$GITLAB_URL/api/v4/projects/$PROJECT_ID"AUTH="PRIVATE-TOKEN: $GITLAB_TOKEN"
echo "=== Audit report, project $PROJECT_ID, since $SINCE ==="
echo ""echo "--- Protected branch changes ---"curl -s -H "$AUTH" "$API/audit_events?created_after=$SINCE" \ | jq -r '.[] | select(.details.change == "protected_branch") | "\(.created_at) | \(.details.author_name) | \(.details.custom_message)"'
echo ""echo "--- CI variable changes ---"curl -s -H "$AUTH" "$API/audit_events?created_after=$SINCE" \ | jq -r '.[] | select(.details.change == "ci_variable") | "\(.created_at) | \(.details.author_name) | \(.details.custom_message)"'
echo ""echo "--- Production deployments ---"curl -s -H "$AUTH" "$API/deployments?environment=production&updated_after=$SINCE" \ | jq -r '.[] | "\(.created_at) | \(.deployable.user.name // "system") | \(.status) | ref:\(.ref)"'
echo ""echo "--- MRs merged on main ---"curl -s -H "$AUTH" "$API/merge_requests?state=merged&target_branch=main&updated_after=$SINCE" \ | jq -r '.[] | "!\(.iid) | \(.merged_at) | \(.merged_by.name) | \(.title)"'Compliance frameworks
The previous sections were for observing after the fact. A compliance framework is for enforcing upstream: it is the GitLab object that attaches a project to a given regulation and triggers the associated controls. This feature belongs to the Ultimate edition, with partial workarounds described below for the other editions.
The mechanism
A compliance framework is a label applied to a project to indicate that it is subject to a specific policy. It serves two purposes:
- Visibility: quickly identify which projects are subject to which regulation
- Enforcement: automatically run security jobs on the projects concerned, today through a pipeline execution policy (the historical "compliance pipeline" field of the framework is deprecated, see below)
Create a compliance framework
The framework is defined once at the group level, then attached to as many projects as needed: there is no project-level framework. The three steps below cover creation, attachment and verification through the API, the latter being the only reliable way to audit a fleet of projects in bulk.
-
Create the framework at the group level
Group → Settings → General → Compliance frameworks → New framework.
Field Example Name SOC 2, Production Description Projects subject to SOC 2 Type II controls Color #FF0000 Compliance pipeline (deprecated) Leave empty on a new project: enforcement goes through a pipeline execution policy -
Attach the framework to a project
Project → Settings → General → Compliance framework → select the framework.
The label appears on the project page and in the project lists.
-
Check via the API
Terminal window curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/projects/$PROJECT_ID" \| jq '.compliance_frameworks'Expected result:
["SOC 2, Production"]
Pipeline execution policies: the recommended path
To enforce security jobs across a set of projects, the recommended path is the pipeline execution policy, a security policy that injects or enforces a pipeline defined by the security team. It was introduced in GitLab 17.2 and has been generally available since GitLab 17.3. It belongs to the Ultimate edition, like compliance frameworks.
A pipeline execution policy is declared in a security policy project rather than in a field of the framework. It targets projects, groups or a compliance framework, and the developer can neither modify nor remove it from their own .gitlab-ci.yml. The detail of the syntax and the injection modes can be read in the official documentation (see the sources at the end of the page): choose it for any new project.
Compliance pipelines
The framework puts a label on; the compliance pipeline is what makes it binding. It is a pipeline file hosted outside the project, which GitLab runs instead of the repository's own, and on which the developer has no write access. This inversion of control guarantees that a security job really runs; it is GitLab's historical mechanism for this, today deprecated in favor of the pipeline execution policies of the previous section.
The problem
A developer can modify or delete the security jobs in their .gitlab-ci.yml. Shared includes can be commented out. Nothing guarantees that the vulnerability scan really runs.
The solution: compliance pipeline
A compliance pipeline is a .gitlab-ci.yml maintained by the security team, run automatically on any project attached to a compliance framework. The developer can neither modify nor disable it.
stages: - compliance-check - .pre - build - test - deploy - .post - compliance-report
# This job runs BEFORE the developer's pipelinecompliance-secret-scan: stage: compliance-check image: name: ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f entrypoint: [""] script: - gitleaks detect --source . --verbose --exit-code 1 allow_failure: false
# This job runs AFTER the developer's pipelinecompliance-report: stage: compliance-report image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc script: - echo "Pipeline $CI_PIPELINE_ID, project $CI_PROJECT_PATH" - echo "Commit $CI_COMMIT_SHA, author $GITLAB_USER_NAME" - echo "Branch $CI_COMMIT_BRANCH, ref $CI_COMMIT_REF_NAME" - echo "Compliance check passed at $(date -u)" artifacts: paths: - compliance-*.log expire_in: 1 yearHow it works
In this deprecated mechanism, the compliance pipeline replaces the project's pipeline, through an inversion of control. It uses include: to incorporate the developer's .gitlab-ci.yml between its own stages:
include: - project: '$CI_PROJECT_PATH' ref: '$CI_COMMIT_SHA' file: '.gitlab-ci.yml'
stages: - compliance-check - build - test - deploy - compliance-report
compliance-secret-scan: stage: compliance-check # ... (as above)
compliance-report: stage: compliance-report # ... (as above)The developer keeps control of their build, test and deploy stages. The security team controls the compliance-check and compliance-report stages.
Streaming audit events
Browsing the audit events assumes someone remembers to go and read them. Streaming reverses the logic: GitLab pushes every event, as it happens, to an external system. The usual target is a SIEM (Security Information and Event Management), the platform that centralizes the security logs of the whole company and triggers alerts.
Send the events to a SIEM
For continuous monitoring, GitLab can stream the audit events to an external destination:
-
Configure the destination
Group → Settings → General → Audit events → Streaming → Add streaming destination.
Field Value Destination URL https://siem.example.com/api/gitlab-eventsVerification token Shared token to authenticate the requests -
Event format
GitLab sends an HTTP POST with a JSON payload for each event:
{"id": 5678,"author_id": 42,"entity_id": 7,"entity_type": "Project","event_type": "audit_events","details": {"change": "protected_branch","from": "Developers + Maintainers","to": "No one","author_name": "Alice Martin","target_details": "main"},"created_at": "2026-04-15T10:30:00Z"} -
Check reception
Use a test endpoint to validate the flow:
Terminal window # List the streaming destinationscurl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \"$GITLAB_URL/api/v4/groups/$GROUP_ID/audit_events/streaming/destinations"
Common destinations
GitLab does not offer a dedicated integration per product: it sends an HTTP POST with a JSON body, and it is up to the destination to accept it. The table gives, for the four most widespread platforms, the entry point that can receive this raw format.
| SIEM | Method | Configuration |
|---|---|---|
| Splunk | HTTP Event Collector | HEC URL + token |
| ELK / OpenSearch | HTTP endpoint | Logstash HTTP input or direct Elasticsearch |
| Datadog | HTTP Log endpoint | https://http-intake.logs.datadoghq.eu/api/v2/logs + API key |
| Google Chronicle | Webhook | Ingestion API endpoint |
Compliance dashboard
An auditor asks for a state, not a log. The script that follows queries the current configuration of each project and turns it into a numeric score, reproducible from one month to the next. It depends on no paid edition: all the endpoints used are available in GitLab CE.
Build a project compliance report
Combine the audit data with configuration information to produce a compliance score:
#!/usr/bin/env bash# compliance-score.sh: compliance score for a GitLab projectset -euo pipefail
PROJECT_ID="${1:?Usage: $0 <project_id>}"API="$GITLAB_URL/api/v4/projects/$PROJECT_ID"AUTH="PRIVATE-TOKEN: $GITLAB_TOKEN"score=0total=0
check() { local label="$1" result="$2" total=$((total + 1)) if [[ "$result" == "true" ]]; then score=$((score + 1)) echo " [OK] $label" else echo " [KO] $label" fi}
echo "=== Compliance score, project $PROJECT_ID ==="
# Is the main branch protected?main_protected=$(curl -s -H "$AUTH" "$API/protected_branches" \ | jq 'any(.[]; .name == "main")')check "Main branch protected" "$main_protected"
# Is force push forbidden?no_force=$(curl -s -H "$AUTH" "$API/protected_branches" \ | jq '[.[] | select(.name == "main")] | .[0].allow_force_push == false')check "Force push forbidden on main" "$no_force"
# Are tags protected?tags_protected=$(curl -s -H "$AUTH" "$API/protected_tags" \ | jq 'length > 0')check "Protected tags configured" "$tags_protected"
# Is an MR mandatory on main?no_push=$(curl -s -H "$AUTH" "$API/protected_branches" \ | jq '[.[] | select(.name == "main")] | .[0].push_access_levels | any(.[]; .access_level == 0)')check "Direct push forbidden on main" "$no_push"
# Is MR approval required?approvals=$(curl -s -H "$AUTH" "$API/approval_rules" \ | jq 'any(.[]; .approvals_required > 0)')check "MR approval required" "$approvals"
# Is a pipeline active?has_pipeline=$(curl -s -H "$AUTH" "$API/pipelines?per_page=1&status=success" \ | jq 'length > 0')check "CI pipeline active" "$has_pipeline"
echo ""echo "Score: $score / $total"Expected result:
=== Compliance score, project 42 === [OK] Main branch protected [OK] Force push forbidden on main [KO] Protected tags configured [OK] Direct push forbidden on main [KO] MR approval required [OK] CI pipeline active
Score: 4 / 6Troubleshooting
Most failures encountered on this topic are not bugs but edition limits or insufficient rights on the API token. The table separates the two cases and gives, for each symptom, the check that settles it.
| Symptom | Likely cause | Fix |
|---|---|---|
Empty audit events (API returns []) | CE tier without Premium | Upgrade or use the MR/deployments API as an alternative |
| Compliance framework not available | GitLab Ultimate required | Use topics + shared includes as an alternative |
| Streaming: nothing received on the SIEM side | Wrong URL or invalid token | Test with a direct curl, check the SIEM logs |
| Compliance pipeline does not trigger | Framework not attached to the project | Check Settings → General → Compliance framework |
| Duplicate jobs, failures or missing controls during a migration | Compliance pipeline and pipeline execution policy active together on the same project | Remove the compliance pipeline from the framework before enabling the policy |
Script audit-report.sh: jq: error | No events for the filter | Add // empty to the jq filter to handle empty results |
403 Forbidden on /audit_events | Token without admin or Owner rights | Use a Maintainer token for project events, Owner for group |
| Events missing from the log | Default retention is limited | Configure retention in Admin → Settings (instance) |
Key takeaways
Audit and compliance in GitLab CI rest on five principles:
- Everything is recorded: GitLab traces changes to protected branches, variables, permissions and runners. These events are your source of truth to answer auditors.
- The API is your investigation tool: the interface shows a log, the API allows targeted queries. Automate the reports with scripts so as not to depend on manual browsing.
- Policies enforce, frameworks label: a pipeline execution policy guarantees that security jobs run without depending on the developer's goodwill (GitLab Ultimate). Compliance pipelines, which played this role, have been deprecated since GitLab 17.3: do not adopt them on a new project, and do not combine them with a policy.
- Streaming turns audit into monitoring: sending events to a SIEM lets you detect anomalies in real time, not during the annual audit.
- Compliance is measured: a per-project compliance score, computed automatically, lets you identify at-risk projects before the auditor does.
Sources
- Audit events, GitLab Docs
- Audit events API, GitLab Docs
- Compliance frameworks, GitLab Docs
- Compliance pipelines (deprecated), GitLab Docs
- Pipeline execution policies, GitLab Docs
- Audit event streaming, GitLab Docs
- SOC 2 compliance with GitLab, GitLab
Test your knowledge
Ten questions on audit events, compliance frameworks and streaming, with a pass mark of 70%.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI/CD tools
A new full quiz with random questions
Work again only on the questions you missed