Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: audit, compliance and pipeline traceability

By updated

Improve this page

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:

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:

LevelVisibilityExample events
ProjectMaintainer+MR merged, pipeline started, variable changed, protected branch changed
GroupOwnerMember added/removed, permission changed, shared runner configured
InstanceAdminUser 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.

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.

EventWhy monitor itCategory
protected_branch_created / removedSomeone changed the branch policyAccess control
approval_rule_created / deletedThe MR approval rules changedValidation process
environment_protected / environment_unprotectedThe production environment lost its protectionDeployment
variable_created / updated / deletedA CI secret was modifiedSecrets
runner_registrationA new runner was registeredInfrastructure
member_added / removedProject access permissions changedAccess control
deploy_key_addedA deploy key was addedExternal access
merge_request_approvedAn MR was approved (by whom?)Traceability
pipeline_schedule_createdA scheduled pipeline was createdAutomation

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?"

Terminal window
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?"

Terminal window
# List the MRs merged on main this month
curl -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?"

Terminal window
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 project
set -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:

  1. Visibility: quickly identify which projects are subject to which regulation
  2. 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.

  1. Create the framework at the group level

    Group → Settings → General → Compliance frameworks → New framework.

    FieldExample
    NameSOC 2, Production
    DescriptionProjects 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
  2. 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.

  3. 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"]

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.

compliance-pipeline.yml (in a dedicated project)
stages:
- compliance-check
- .pre
- build
- test
- deploy
- .post
- compliance-report
# This job runs BEFORE the developer's pipeline
compliance-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 pipeline
compliance-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 year

How 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:

compliance-pipeline.yml (version with include)
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:

  1. Configure the destination

    Group → Settings → General → Audit events → Streaming → Add streaming destination.

    FieldValue
    Destination URLhttps://siem.example.com/api/gitlab-events
    Verification tokenShared token to authenticate the requests
  2. 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"
    }
  3. Check reception

    Use a test endpoint to validate the flow:

    Terminal window
    # List the streaming destinations
    curl --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.

SIEMMethodConfiguration
SplunkHTTP Event CollectorHEC URL + token
ELK / OpenSearchHTTP endpointLogstash HTTP input or direct Elasticsearch
DatadogHTTP Log endpointhttps://http-intake.logs.datadoghq.eu/api/v2/logs + API key
Google ChronicleWebhookIngestion 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 project
set -euo pipefail
PROJECT_ID="${1:?Usage: $0 <project_id>}"
API="$GITLAB_URL/api/v4/projects/$PROJECT_ID"
AUTH="PRIVATE-TOKEN: $GITLAB_TOKEN"
score=0
total=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 / 6

Troubleshooting

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.

SymptomLikely causeFix
Empty audit events (API returns [])CE tier without PremiumUpgrade or use the MR/deployments API as an alternative
Compliance framework not availableGitLab Ultimate requiredUse topics + shared includes as an alternative
Streaming: nothing received on the SIEM sideWrong URL or invalid tokenTest with a direct curl, check the SIEM logs
Compliance pipeline does not triggerFramework not attached to the projectCheck Settings → General → Compliance framework
Duplicate jobs, failures or missing controls during a migrationCompliance pipeline and pipeline execution policy active together on the same projectRemove the compliance pipeline from the framework before enabling the policy
Script audit-report.sh: jq: errorNo events for the filterAdd // empty to the jq filter to handle empty results
403 Forbidden on /audit_eventsToken without admin or Owner rightsUse a Maintainer token for project events, Owner for group
Events missing from the logDefault retention is limitedConfigure retention in Admin → Settings (instance)

Key takeaways

Audit and compliance in GitLab CI rest on five principles:

  1. Everything is recorded: GitLab traces changes to protected branches, variables, permissions and runners. These events are your source of truth to answer auditors.
  2. 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.
  3. 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.
  4. Streaming turns audit into monitoring: sending events to a SIEM lets you detect anomalies in real time, not during the annual audit.
  5. Compliance is measured: a per-project compliance score, computed automatically, lets you identify at-risk projects before the auditor does.

Sources

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

10 questions
5 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn