$ duy_
cd ../blog

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.

  • gitlab
  • ci-cd
  • trivy
  • semgrep
  • security

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

JobToolWhat it is for
secrets:trivyTrivycredentials in the working tree
secrets:gitleaksGitleakscredentials anywhere in history
deps:trivyTrivyvulnerable packages, lockfiles and OS
deps:dotnet.NET SDKNuGet advisories Trivy cannot see
iac:trivyTrivyDockerfile, Compose, Kubernetes, Helm, Terraform, Ansible
sast:semgrepSemgrep OSScode-level findings
container:trivyTrivya 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 (and TRIVY_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.