28 Jun 2026 · 7 min read
Security scanning on GitLab CE, where none of the security features exist
The built-in scanning templates are an Ultimate feature. Building the same coverage from Trivy, Gitleaks and Semgrep — and finding the one security-shaped report a Community Edition merge request will actually render.
I am standing up a self-hosted GitLab Community Edition instance to run my project pipelines. The obvious first move is GitLab’s own security templates:
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
- template: Security/Dependency-Scanning.gitlab-ci.yml
On CE, that gets you a partial, confusing result — and understanding which part is missing turns out to shape the entire design.
What the tiering actually costs you
Two different things are gated, and conflating them is what makes this confusing.
The analyzers. Some run on Free/CE, some do not. SAST and Secret Detection execute and produce their report JSON. Dependency Scanning, Container Scanning and License Scanning are Ultimate — the jobs are not going to do useful work for you.
The rendering. This is the part that surprises people. Even for the analyzers that
do run, the merge-request security widget, the Vulnerability Report and the Security
Dashboard are all Ultimate. So on CE you can happily produce a valid gl-sast-report.json
via artifacts:reports:sast and then discover that nothing anywhere in the UI displays
it. The keyword is accepted. The report is uploaded. It is simply never shown.
The result is the worst of both worlds: a pipeline that looks instrumented, jobs that take minutes, and findings that exist only inside a job log nobody opens.
(Tiering moves between releases — check your own instance rather than trusting a blog post, mine included. The shape of the argument holds regardless: on CE, assume report rendering is the thing you do not have.)
So I stopped using the templates. Every job drives an open-source scanner directly, which means identical behaviour on CE, no licence-shaped surprises, and artifacts that belong to me.
The scanner set, and why each one is there
| Job | Tool | What it is for |
|---|---|---|
secrets:trivy | Trivy | credentials in the working tree |
secrets:gitleaks | Gitleaks | credentials anywhere in history |
deps:trivy | Trivy | vulnerable packages, lockfiles and OS |
deps:dotnet | .NET SDK | NuGet advisories Trivy cannot see |
iac:trivy | Trivy | Dockerfile, Compose, Kubernetes, Helm, Terraform, Ansible |
sast:semgrep | Semgrep OSS | code-level findings |
container:trivy | Trivy | a built image, opt-in via SCAN_IMAGE |
Three of those need justifying.
Two secret scanners is not redundancy. Trivy scans the working tree. Gitleaks walks every commit. A credential deleted in a later commit is invisible to the first and obvious to the second, and it is still served to everyone who clones. Different questions, different tools:
variables:
GIT_DEPTH: '0' # gitleaks reads history; a shallow clone hides almost all of it
That variable is load-bearing. GitLab’s default GIT_DEPTH of 20 means Gitleaks
faithfully scans the last twenty commits and reports a clean repository.
deps:dotnet exists because Trivy has a real blind spot. Trivy resolves NuGet
dependencies from packages.lock.json, and most .NET repositories do not commit one —
it is opt-in behind RestorePackagesWithLockFile. So Trivy reports zero findings on a
.NET solution with a hundred transitive packages, and zero findings reads as “clean”. The
SDK already knows the graph, so ask it:
deps:dotnet:
image: mcr.microsoft.com/dotnet/sdk:8.0
script:
- dotnet restore || true
- dotnet list package --vulnerable --include-transitive 2>&1 | tee dotnet-vulnerable.txt
rules:
- exists: ['**/*.csproj', '**/*.sln']
Semgrep runs with --metrics=off. The OSS rulesets are genuinely good across C#, Go,
Python, Java and TypeScript, and none of it needs an account. Worth knowing before you
put it on an internal instance.
Code Quality is the report CE renders
Here is the part that makes the whole thing worth building rather than just reading logs.
Code Quality is available on Free/CE, and it renders inline on the merge request diff — annotated on the exact line, next to the change that introduced it. It is a generic format, but nothing in it says the issues have to come from a linter.
So every scanner’s output gets converted into one Code Quality report:
{
"type": "issue",
"description": "CVE-2024-0002: minimist 1.2.0 — Arg injection (no fix available)",
"check_name": "CVE-2024-0002",
"severity": "blocker",
"fingerprint": "f24979add06b948deb5b046857f81b1ac42ba829410c37b5b7c3f3f259d4573b",
"location": { "path": "package-lock.json", "lines": { "begin": 1 } }
}
Four design decisions in there that took some iterating:
One merge job, not one report per scanner. GitLab will merge Code Quality reports
across jobs, so per-scanner reports would work. They are the wrong shape anyway, for a
practical reason and a design reason. Practically: the scanner images are minimal Alpine
and none of them ship jq, so every job would need its own tool install and its own copy
of the conversion logic. By design: severity mapping and de-duplication are decisions, and
decisions belong in one place. Trivy’s secret scanner and Gitleaks will find the same
credential; something has to notice.
Fingerprints must be stable. GitLab uses them to tell a new finding from one that was already there. A hash of a composite natural key — target, rule id, package, line — is stable across runs and changes when the finding genuinely changes:
def fingerprint(*parts: object) -> str:
"""Stable across runs so GitLab can tell a new finding from an old one."""
return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()
Severity mapping is a policy, not a lookup. Trivy’s CRITICAL/HIGH/MEDIUM/LOW maps onto Code Quality’s blocker/critical/major/minor cleanly enough. Secrets do not:
# A secret in the tree has no "wait for a fix" state, only "revoke it".
severity = "blocker"
A CVE with no available patch is a risk you carry deliberately. A leaked credential is not a risk at all — it is a task. Ranking them on the same scale is the small dishonesty that makes a vulnerability list feel like weather.
needs: optional: true everywhere. Several scan jobs are conditional (deps:dotnet
only where a .csproj exists, container:trivy only where SCAN_IMAGE is set). Without
optional, a skipped dependency makes the report job unresolvable and the pipeline
refuses to even start. And every job carries artifacts: when: always, because a job that
fails on a finding still has to upload the finding.
The gate
Secret detection blocks unconditionally. Everything else is report-only by default:
variables:
# Start empty on an existing codebase and tighten to 'critical' once the
# backlog is triaged — a gate nobody can turn green is a gate everyone
# learns to ignore.
SECURITY_FAIL_ON: ''
I feel strongly about this ordering, having watched it go the other way. Turn on
dependency scanning at HIGH on a codebase that has never been scanned and every pipeline
goes red on day one over transitive CVEs in packages nobody chose. Within a week the team
has learned that red means nothing, and allow_failure: true appears in a commit titled
“unblock CI”. You have spent your credibility and bought negative security.
Report-only first. Triage once. Then tighten, and mean it.
Secrets are the exception because the response is not a judgement call. There is no backlog of credentials you are choosing to live with — each one is a revocation, and the work is bounded.
Running it on a Kubernetes runner
The runner will live in-cluster, next to a Harbor registry, which changes a few things:
.trivy:
cache:
# The vulnerability DB is a few hundred MB and changes daily. Cache it per
# branch-agnostic key so the first job of a pipeline pays for it once.
key: 'trivy-db'
paths: ['.trivycache']
- Mirror the databases. Trivy pulls its vulnerability DB as an OCI artifact.
TRIVY_DB_REPOSITORY(andTRIVY_JAVA_DB_REPOSITORY) can point at a Harbor proxy cache, which is the difference between “works” and “works air-gapped” — and, on a normal day, the difference between a fast pipeline and one that re-downloads hundreds of megabytes per job. - Pin the images, then pin the digests. Version tags are pinned in the template today; once they are mirrored into Harbor, digests. A scanner that silently changes version is a scanner whose findings you cannot compare over time.
- Raise the memory limit. Semgrep on a repository of any size will exceed the modest
default a
[runners.kubernetes]block tends to carry, and OOM inside Semgrep looks like a mysterious exit rather than an OOM. - Add a nightly schedule. Advisory databases change even when your code does not. A
scheduled pipeline on the default branch is how a new CVE against an untouched
dependency reaches you. Every job already has a
$CI_PIPELINE_SOURCE == "schedule"rule.
The wart
include: brings YAML, not files. So the converter — a hundred and something lines of
Python — is embedded in the template as a heredoc:
.codequality_converter:
script:
- |
cat > to-codequality.py <<'PYEOF'
#!/usr/bin/env python3
...
PYEOF
This is not lovely. The alternatives are worse for a first setup: fetching the script at runtime adds a network dependency and an auth story, and a multi-project checkout means every consuming project needs read access to the template project. Inlining keeps the template includable on its own, with nothing else to configure.
The mitigation is that the embedded copy is generated, not pasted. scripts/to-codequality.py
is the source of truth and a sync script re-embeds it:
python3 scripts/sync-template.py
Which leaves the obvious question of whether the embedded copy actually matches. I tested
that the way you would test any generated artifact — by reversing the generation. Parse the
YAML, pull out .codequality_converter.script[0], run it in an empty directory, and diff
the file it produces against the source:
extracted 204 lines
byte-identical to the source of truth
Then run the extracted copy against synthetic output from all five input formats — Trivy
vulnerabilities, Trivy misconfigurations, Trivy secrets, Semgrep, Gitleaks — and check the
gate behaves: exit 0 with SECURITY_FAIL_ON empty, exit 1 at critical, correct
de-duplication and ordering. That is a five-minute test that turns “the heredoc is
probably fine” into something I would put on a default branch.
What this does not do
No dashboard. No cross-project vulnerability trend. No “accept this risk until March” workflow. Those are the genuinely valuable parts of the Ultimate offering and this does not replace them — it replaces the detection, which was always the commodity half.
For one engineer and a handful of repositories, findings annotated on the diff of the merge request that introduced them is most of the value anyway. It puts the finding in front of the person who caused it, at the moment they can most cheaply fix it, which is the only property of a security tool that reliably changes behaviour.