Single-tenant · On-prem Checkmarx · Self-hosted GitLab

GitLab + Checkmarx
Complete Setup Guide

Every push and MR automatically triggers a Checkmarx scan. Results appear natively inside GitLab — no manual trigger, no webhook, no polling required.

CxFlow GitLab CI/CD SAST Auto-scan Security Dashboard On-prem

What you're building

A fully automated security scanning pipeline. Once set up, no developer needs to do anything — pushing code is enough to trigger a scan.

TRIGGER
Push or open MR
Any branch, any repo under the group
SCAN
Checkmarx SAST
Full scan on main, incremental on MRs
RESULTS
GitLab Security tab
Findings in pipeline + MR widget
GATE
Pipeline fails
On any HIGH or CRITICAL finding

Architecture

GitLab CI drives everything. The runner invokes CxFlow which talks to Checkmarx. Checkmarx never reaches back into GitLab — clean, firewall-friendly.

Developer pushes code
GitLab detects .gitlab-ci.yml
GitLab Runner fires (ephemeral)
REST API
CxFlow container (pinned digest)
internal network
Checkmarx Manager
Checkmarx Scan Engine (:8088)
gl-sast-report.json → GitLab Security tab

STEP 01

Create Checkmarx Service Account

This is done by whoever administers your Checkmarx instance. Do not use a personal account or the built-in cxadmin — create a dedicated service account.

1 — Log into Checkmarx Web UI as admin

url
https://checkmarx.company.local

Use the cxadmin account or any account with User Management permissions.

2 — Navigate to User Management

path
Top menu → Administration → User Management

3 — Create new user — fill in these exact values

FieldValue
Usernamesvc-gitlab-ci
PasswordGenerate 20+ chars — save it immediately in a password manager
Confirm Passwordsame as above
EmailA team mailbox e.g. devops@company.com
First NameGitLab
Last NameCI Service
Account Active✓ checked
Password never expires✓ checked — critical, see warning below
Password never expires — must be checked If the password expires silently, every pipeline in every repo starts failing at 3am with a 401 and no obvious reason why. Always check this box for CI service accounts.

4 — Assign roles — Scanner only

On the same screen under Roles:

RoleSet
SAST Scanner✓ check this
Admin✗ leave unchecked
Data Analyst✗ leave unchecked
Manager✗ leave unchecked
Least privilege Scanner role is the minimum needed to authenticate and submit scans. Nothing more. This limits blast radius if the token is ever compromised.

5 — Click Save

The account is now created. Move to Step 02 to verify it works before touching GitLab.


STEP 02

Verify the Checkmarx Account

Run this from the GitLab Runner host or any machine that can reach Checkmarx on port 443. Do this before setting any GitLab variables.

bash — auth test
curl -X POST https://checkmarx.company.local/cxrestapi/auth/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=svc-gitlab-ci" \
  -d "password=<the-password-you-set>" \
  -d "grant_type=password" \
  -d "scope=sast_rest_api" \
  -d "client_id=resource_owner_client" \
  -d "client_secret=014DF517-39D1-4453-B7B3-9930C563627C"
Success — you'll see this {"access_token":"eyJhbGci...","token_type":"Bearer","expires_in":3599}
You have a valid token. The account works. Proceed to Step 03.

If it fails

ErrorCauseFix
401 UnauthorizedWrong password or account not saved correctlyReset password in Checkmarx User Management
Connection refusedNetwork / firewall issue — not an account problemVerify port 443 is open between runner and Checkmarx
400 Bad RequestMissing or malformed POST body fieldsCheck all -d params are present and URL-encoded

STEP 03

Create GitLab API Token

You generate this yourself in GitLab. CxFlow uses it to post scan results back to MRs and the Security Dashboard.

1 — Navigate to Access Tokens

path
GitLab → Top-right avatar → Edit Profile → Access Tokens → Add new token

2 — Fill in these values

FieldValue
Token namecxflow-scanner
Expiry date90 days from today — set a calendar reminder to rotate
Scopesapi only — everything else unchecked

3 — Click Create token

Copy the token immediately GitLab shows it exactly once. If you close the page without copying it, you must delete it and create a new one. There is no way to retrieve it again.

Store the token in a password manager or Vault right now. You'll paste it into GitLab CI/CD Variables in the next step.


STEP 04

Set Group-Level CI/CD Variables

Set these at the group level — not per-project. Every repo under that group inherits them automatically. You only do this once.

1 — Navigate to Variables

path
GitLab → Your Group → Settings → CI/CD → Variables → Expand → Add variable

2 — Add these four variables exactly

Variable nameValueProtectedMasked
CX_SERVERe.g. https://checkmarx.company.localNoNo
CX_USERNAMEsvc-gitlab-ciYesNo
CX_PASSWORDthe password you set in Step 01YesYes
GITLAB_TOKENthe token you copied in Step 03YesYes
Protected vs Masked explained Protected = variable is only available in pipelines running on protected branches (main, develop). Prevents feature-branch pipelines from accessing secrets.
Masked = value is redacted from job logs even if accidentally printed. Always mask passwords and tokens.
CX_SERVER has no trailing slash https://checkmarx.company.local
https://checkmarx.company.local/ ✗ — CxFlow will build malformed API URLs.

STEP 05

Add .gitlab-ci.yml to the Repo

Create this file in the root of every repo you want scanned. If you want it on all repos without touching each one, use GitLab's Compliance Pipelines or include: from a central project.

yaml — .gitlab-ci.yml
# Fire on every push to any branch AND on every MR
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH'

stages:
  - sast

checkmarx-sast:
  stage: sast

  # Pin by SHA256 digest — never use :latest (see Step 06)
  image: checkmarx/cx-flow@sha256:<your-pinned-digest>

  variables:
    # Full scan on default branch, incremental (changed files only) on everything else
    IS_INCREMENTAL: >-
      $([[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]] && echo false || echo true)

  script:
    - |
      java -jar /app/cx-flow.jar \
        --scan \
        --cx-server="${CX_SERVER}" \
        --cx-user="${CX_USERNAME}" \
        --cx-password="${CX_PASSWORD}" \
        --cx-project="${CI_PROJECT_NAME}" \
        --app="${CI_PROJECT_NAME}" \
        --branch="${CI_COMMIT_REF_NAME}" \
        --repo-name="${CI_PROJECT_NAME}" \
        --namespace="${CI_PROJECT_NAMESPACE}" \
        --merge-id="${CI_MERGE_REQUEST_IID:-}" \
        --bug-tracker=GitLabDashboard \
        --f="${CI_PROJECT_DIR}" \
        --cx-flow.filter-severity=HIGH,CRITICAL \
        --incremental="${IS_INCREMENTAL}"

  # This is what makes results appear in GitLab UI
  artifacts:
    when: always          # upload report even if job fails
    reports:
      sast: gl-sast-report.json   # → Pipeline Security tab + MR widget
    paths:
      - gl-sast-report.json
    expire_in: 30 days

  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH && $CI_PIPELINE_SOURCE != "schedule"'

  allow_failure: false    # pipeline goes red on HIGH/CRITICAL findings
Key lines explained --bug-tracker=GitLabDashboard → tells CxFlow to write gl-sast-report.json in GitLab's SAST schema
artifacts.reports.sast → tells GitLab to read that file and render it in the Security tab
when: always → report uploads even when the job fails so you always see why
allow_failure: false → pipeline fails hard on HIGH/CRITICAL so it blocks merges

STEP 06

Pin the CxFlow Image Digest

Image tags like :latest are mutable — upstream can overwrite them silently. A SHA256 digest is immutable. Run this once on any machine with Docker access.

bash
docker pull checkmarx/cx-flow:latest
docker inspect --format='{{index .RepoDigests 0}}' checkmarx/cx-flow:latest

Output looks like:

output
checkmarx/cx-flow@sha256:a1b2c3d4e5f6789abc...

Copy the sha256:... part and paste it into your .gitlab-ci.yml:

yaml
# Before — unsafe
image: checkmarx/cx-flow:latest

# After — pinned, reproducible, auditable
image: checkmarx/cx-flow@sha256:a1b2c3d4e5f6789abc...
When to re-pin Only when you intentionally want to upgrade CxFlow. Pull the new version, get its digest, update the file, commit. That's your audit trail — the git history shows exactly when and what changed.

STEP 07

Test the Pipeline

Commit the .gitlab-ci.yml to the repo and push. Then verify everything ran correctly.

1 — Trigger the first scan

bash
# If the file is already in the repo, a push triggers it
git add .gitlab-ci.yml
git commit -m "chore: add Checkmarx SAST pipeline"
git push origin main

2 — Watch the pipeline

path
GitLab → CI/CD → Pipelines → click the running pipeline → checkmarx-sast job

3 — Confirm the report was uploaded

In the pipeline job detail page, look for the artifacts section — gl-sast-report.json should be listed. If it's there, GitLab picked it up.

4 — Confirm scan ran in Checkmarx

path
Checkmarx Web UI → Projects → look for a project named after your GitLab repo

Checkmarx auto-creates the project on first scan using $CI_PROJECT_NAME.


Where Results Appear in GitLab

Pipeline Security tab — all tiers

path
CI/CD → Pipelines → <your pipeline> → Security tab

Lists every finding from that scan with severity, CWE, file, and line number.

MR security widget — all tiers

path
Merge Requests → <your MR> → scroll to Security widget at bottom of page

Shows only new findings introduced by that MR — not pre-existing ones. This is what blocks the merge when allow_failure: false.

Project Security Dashboard — GitLab Ultimate only

path
Security → Dashboard

Trend view of all findings over time across all scans in the project.


Scan Behavior Reference

TriggerScan typeWhat gets scannedPipeline result on finding
Push to main / default branchFull scanEntire codebaseFails on HIGH / CRITICAL
Push to any other branchIncrementalChanged files only — fasterFails on HIGH / CRITICAL
MR opened or updatedIncrementalChanged files onlyFails — blocks merge
Scheduled pipelineSkippedNot triggered by schedule
Why incremental on MRs Scanning only changed files makes MR pipelines significantly faster. A full scan still runs on every merge to main, so nothing escapes — incremental scans are a speed optimization, not a security compromise.

Go-Live Checklist


Troubleshooting

401 Unauthorized — pipeline fails immediately

The CxFlow container cannot authenticate to Checkmarx. Check in order:

1. CX_USERNAME and CX_PASSWORD variables are set at group level with correct values — typos are common

2. Re-run the Step 02 curl test manually from the runner host to confirm the account itself works

3. The account may be locked after repeated failed attempts — unlock it in Checkmarx User Management

4. Check the CX_SERVER value has no trailing slash

Pipeline runs but Security tab is empty / no report

The scan ran but gl-sast-report.json was not produced or not picked up by GitLab.

1. Confirm --bug-tracker=GitLabDashboard is in your script — this flag is what tells CxFlow to write the GL-schema JSON

2. Check the artifacts section of the job — is gl-sast-report.json listed? If not, CxFlow failed to write it

3. Confirm artifacts.reports.sast: gl-sast-report.json is in the CI file with correct indentation

4. Check CxFlow container logs for errors writing the report file

Scan hangs or times out

Usually a network issue between CxFlow and the Checkmarx Scan Engine.

1. Confirm port 8088 is open between the GitLab Runner network and the Checkmarx Engine host

2. Large repos (>500k LOC) on first scan can take 60–120+ minutes — incremental scans are much faster

3. Check Checkmarx Engine has sufficient CPU/memory — scans queue up and appear hung if the engine is saturated

SSL / TLS errors in CxFlow container

Your Checkmarx instance uses an internal CA cert that the CxFlow JVM doesn't trust.

Fix: build a custom CxFlow image that imports your corporate root CA:

dockerfile
FROM checkmarx/cx-flow@sha256:<pinned-digest>
COPY corp-root.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates \
 && keytool -import \
      -trustcacerts \
      -alias corp-root \
      -file /usr/local/share/ca-certificates/corp-root.crt \
      -keystore $JAVA_HOME/lib/security/cacerts \
      -storepass changeit \
      -noprompt

Push this image to your internal registry and use it instead of the public CxFlow image.

Variables not available in pipeline — job fails with empty vars

Variables set as Protected are only available on protected branches. If you're testing on a feature branch:

1. Either temporarily set the variables as unprotected to test

2. Or protect the branch you're testing on: GitLab → Settings → Repository → Protected branches

3. Once confirmed working, re-apply Protected flag and keep Protected branches policy

MR widget not showing security findings

The MR security widget only shows findings from pipelines triggered by the MR event — not from a branch push pipeline.

1. Confirm the pipeline was triggered by CI_PIPELINE_SOURCE == "merge_request_event" — check the pipeline variables in the job detail

2. The MR must be open when the scan completes — results from a closed/merged MR don't retroactively appear

3. GitLab Ultimate is required for the group-level Security Dashboard. The MR widget itself works on all tiers.