Skip to content

GitLab CI

The Veridor repository ships a GitLab CI/CD component, templates/veridor.yml: one gate job that runs veridor run, publishes JUnit, GitLab SAST and Code Quality reports through artifacts:reports, and exits with Veridor's own exit code. ci/.gitlab-ci.yml is a consumer example.

Not published, and not run on GitLab

No release tag, container image (ghcr.io/veridor-authz/veridor), PyPI package or GitLab CI/CD Catalog entry exists, and the source repository, github.com/Veridor-authz/veridor, is private. The component's defaults name the first release (0.1.0), and neither include form below resolves until that release exists. The component has never run on a GitLab runner; what was executed instead is listed at the end of this page.

Include it, pinned to a commit

Remote form: the raw file, at the commit the release tag points to, with an integrity hash. It works on any GitLab instance that can reach raw.githubusercontent.com, once the repository is public and the release exists:

include:
  - remote: "https://raw.githubusercontent.com/Veridor-authz/veridor/COMMIT_SHA_OF_THE_RELEASE_TAG/templates/veridor.yml"
    integrity: "sha256-REPLACE+with+the+base64+SHA256+of+that+file="
    inputs:
      config: veridor.json
      base_url: "https://staging.example.com"
      fail_on: new+confirmed
      allow_unmeasured: false

Not the tag URL (.../veridor/v0.1.0/templates/veridor.yml). GitLab fetches a remote include again for every pipeline, with no notice when it changes, and the included job runs with your project's CI/CD variables, credentials included; a tag can be moved. So:

  1. Put the full commit SHA the release tag points to in the URL: git ls-remote https://github.com/Veridor-authz/veridor 'refs/tags/<tag>^{}'.
  2. Set integrity to sha256- followed by the base64 SHA-256 of the file at that commit: curl -fsSL "<the URL>" | openssl dgst -sha256 -binary | base64. integrity needs GitLab 17.9 or later; on an older GitLab, drop the line and keep the commit URL.

Left as shown, both placeholders stop the pipeline rather than run anything (GitLab's documentation says an include it cannot fetch, or whose integrity does not match, fails it; read there, not observed on GitLab).

CI/CD Catalog form, once the repository is mirrored to GitLab and released there. The address assumes a mirror at gitlab.com/Veridor-authz/veridor, which does not exist, and nothing shows that the gitlab.com/Veridor-authz namespace belongs to this project: whoever holds it could publish a component at that address, and it would run with your CI/CD variables, so check the holder first. A component version can also be a full commit SHA, which does not move the way a tag can:

include:
  - component: gitlab.com/Veridor-authz/veridor/[email protected]
    inputs:
      config: veridor.json
      base_url: "https://staging.example.com"

To gate two configurations, include the component twice with two job_name values.

Run it after the deploy

The gate probes whatever base_url serves when the job starts. The default stage is test, which GitLab runs before its default deploy stage, so aimed at a target the same pipeline deploys it would measure the previous deployment (inferred from GitLab's stage order, not run on GitLab). Give it a later stage, or put it in the deploy job's stage with needs: on that job:

stages: [build, test, deploy-staging, verify-staging, deploy-production]
# and stage: verify-staging in the component's inputs

To gate a review app, have the deploy job write the app's URL to a dotenv report and pass base_url: $REVIEW_APP_URL, then add a job of the same name with only the keys to add; GitLab merges it into the included job:

deploy-review:
  script:
    - ./deploy-review.sh
    - echo "REVIEW_APP_URL=https://review-$CI_COMMIT_REF_SLUG.example.com" >> review.env
  artifacts:
    reports:
      dotenv: review.env

veridor:
  needs: [{job: deploy-review, artifacts: true}]

Do that only where pipeline variables are restricted: a pipeline variable of the same name outranks the dotenv value. Not $CI_ENVIRONMENT_URL: GitLab sets it only in a job that declares an environment, and the gate job declares none, so the job would stop with exit 2 on every pipeline.

Inputs

From the component's spec: inputs; the names, types, defaults and options below were compared for this page with templates/veridor.yml from an earlier tree (before 38025ed).

Input Type Default What it does
stage string test The stage the job runs in. Run it after the job that deploys its target (above).
job_name string veridor The job's name (^[A-Za-z0-9][A-Za-z0-9_.:-]*$).
install image | pip image image runs the Veridor container image; pip installs pip_package into python_image, for runners that cannot pull it.
image string ghcr.io/veridor-authz/veridor:0.1.0 The Veridor image (install: image). The job clears its veridor entrypoint. Pin a digest once one is published.
python_image string python:3.12-alpine@sha256:4c47124a8391cb7a9f571164147d154777cf012a4ece5f86097130d7a4478111 A Python 3.11+ image (install: pip), pinned by digest: the job installs and runs the gate in it with every credential, and a tag can be moved. The digest is the one the release Dockerfile builds on. It has no bash, and the job's script needs none.
pip_package string veridor==0.1.0 What pip installs (install: pip): a pinned requirement, or the path of a wheel in the project. The default is not on PyPI (below). It names code the job runs, so never forward untrusted text into it.
config string veridor.json The configuration, relative to the project directory.
base_url string empty Overrides target.base_url. Every credential is sent here: prefer a literal URL. A variable reference is expanded when the job runs; one that expands to nothing is refused (exit 2), not ignored.
proxy_from_environment boolean false false runs Veridor without any variable whose name ends in _proxy, in any case, and the log names what it removed. true keeps them, for a target reachable only through a proxy. See proxies.
wait_for_target number 0 --wait-for-target: seconds to wait, before the first request, for the target's host and port to accept a TCP connection. 0 passes no flag; anything but a whole number is refused with exit 2. It never changes a verdict.
fail_on new+confirmed | new | confirmed | any | none new+confirmed --fail-on. The default fails on every finding not in the baseline, and on a confirmed one even when the baseline holds it. A suspected or ungraded finding stops failing once its id is in the baseline.
require_complete boolean true --require-complete: an unanswered probe exits 3.
allow_unmeasured boolean false Show exit 3, and only exit 3, as a warning instead of failing the job.
baseline string empty --baseline: a prior run JSON. A path that does not exist is exit 2. It is part of your merge request's diff, so require Code Owner approval for it (CODEOWNERS; GitLab Premium).
openapi string empty --openapi: enables boundary coverage.
output_dir string veridor-out Relative directory for the run history and every report. Must be writable by the job's user.
extra_args string empty More veridor run flags, split on spaces, such as --read-only --require-clean-teardown.
artifacts_expire_in string 30 days artifacts:expire_in.
job_rules array [{when: on_success}] The job's rules. The default puts the gate in every pipeline your workflow creates; see where the job runs. A rule's allow_failure overrides the job's own, as GitLab documents for rules, and the component cannot check the rules you pass: leave allow_failure out of them.

Input rules

GitLab writes input text into the job's script before the shell reads it. So every string input that lands there (base_url, config, pip_package, baseline, openapi, extra_args) refuses a $ that does not begin a $NAME or ${NAME} reference. $(...), $((...)) and ${NAME:...} fail when the pipeline is created, instead of running in the job that holds your credentials. The CHANGELOG records the old regexes accepting such values: $(touch ...) in base_url, config, baseline, openapi or pip_package created its file when the rendered script ran, and ${HOME:X} evaluated X as arithmetic, which can run a command. extra_args no longer accepts literal braces. A $NAME whose value holds $(...) is expanded, not executed.

extra_args may add flags, never replace one the component sets from its own inputs. --config, --output-dir, --json, --fail-on, --base-url, --baseline, --openapi, --wait-for-target and --help are refused with exit 2, abbreviations included: argparse accepts a unique prefix, so --fail none would otherwise be --fail-on none.

Where the job runs

By default, in every pipeline your workflow creates (job_rules: [{when: on_success}]). The old default, merge request pipelines plus the default branch, left the job out of 13 of 24 kinds of pipeline, among them the branch pipeline for a merge request's branch, tags and a child of a merge request pipeline, with nothing to show it was missing; under GitLab's Branch-Pipelines workflow the gate never ran before merge.

The cost: without workflow:rules, a push to a branch with an open merge request makes a branch pipeline and a merge request pipeline, and the gate runs in both. To run it once, pick one pipeline type with GitLab's "switch between branch pipelines and merge request pipelines" recipe:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH

That also stops tag pipelines, so the gate no longer runs on a tag. Setting job_rules instead restricts the gate itself, and each pipeline the rules leave out runs without it.

These rules were modelled, not observed: the Veridor test suite (JobRulesTest) evaluates the rendered rules against the variables GitLab documents for 24 kinds of pipeline, under four workflow:rules. No GitLab instance was used.

Who may set what

Veridor sends every identity's credential to base_url, and veridor run asks for no acknowledgement, so whoever may set the base URL decides where every VERIDOR_* credential goes. A literal base_url in your .gitlab-ci.yml changes only by a commit. A $VARIABLE there is filled when the job runs, and a pipeline variable (set when someone runs a pipeline, or by a trigger or a schedule) outranks dotenv, project and .gitlab-ci.yml variables of the same name.

A literal is not the whole answer, because a pipeline variable of any name still reaches the job:

  • http_proxy or https_proxy, in any case, would carry every request to a proxy of the caller's choosing; the component removes them unless proxy_from_environment is true (proxies);
  • with install: pip, PIP_INDEX_URL (and PIP_EXTRA_INDEX_URL, PIP_FIND_LINKS) picks the code the job installs and runs with the credentials.

So wherever someone who may not read the credentials can run a pipeline, set the project's Minimum role to use pipeline variables to maintainer or no_one_allowed, whatever base_url says. GitLab's documentation gives no_one_allowed as the default for new projects in new namespaces on GitLab.com, and maintainer on Self-Managed (read there, not observed).

The same goes for the settings that can weaken the gate. Under the default fail_on, a suspected or ungraded finding whose id is in the baseline no longer fails the job, and the baseline file is part of the merge request's diff: the CHANGELOG records the rendered job at the default policy exiting 1, then 0 with that run as the baseline. A confirmed finding still exits 1. Require Code Owner approval for the baseline file, or use fail_on: any.

Proxies

Veridor follows http_proxy or https_proxy, in either case, for a target that is not loopback (configuration), and a proxy reads every request to an http:// target, credentials included. The CHANGELOG records the job before this input existed, with the literal base URL http://staging.invalid:8080, sending 9 requests carrying the owner, other-tenant and admin tokens to a capture server named only by an http_proxy or HTTP_PROXY pipeline variable, measured outside GitLab.

So by default the job runs Veridor without any variable whose name ends in _proxy (the set urllib reads, no_proxy included), and prints one line naming what it removed (names only; a value can carry a proxy password). With http_proxy and HTTP_PROXY set, the template's line reads:

veridor-gate: removed http_proxy HTTP_PROXY from veridor's environment (proxy_from_environment is false), so its requests go straight to the target, not through a proxy a pipeline variable could name. Set proxy_from_environment: true for a target reachable only through a proxy.

With proxy_from_environment: true it keeps them, says so, and Veridor names the proxy on stderr before its first request. Set it only for a target reachable only through a proxy, and only where pipeline variables are restricted as above. The install: pip step runs before this and keeps the variables either way.

What the job does

  1. Checks it can write output_dir, by creating it and a probe file, and stops with exit 2 before sending anything if it cannot. The message names the uid, the directory and the two runner options below. Veridor makes the same check itself, but its advice (--output-dir /tmp/veridor, docker run --user) is not something a GitLab job can act on.
  2. Refuses an input that names a variable which expanded to nothing. If base_url, baseline or openapi was given and is empty at run time, the job stops with exit 2 and says what leaving the input empty would mean (for base_url: probing the configuration's own target.base_url).
  3. Refuses a wait_for_target that is not a whole number of seconds, with exit 2.
  4. Checks extra_args as above.
  5. Refuses, with exit 2, an image without env, sed or printenv, which it needs to find the proxy variables; then removes, or keeps, them (proxies).
  6. Traps SIGTERM in the job shell, so that when the runner cancels the job the shell survives until Veridor has run its teardown and exited 143, and the exit line still reaches the log.
  7. Runs:

    veridor run --config <config> --output-dir <out> --fail-on <fail_on> \
      --json <out>/veridor-run.json \
      --report gitlab-sast=<out>/gl-sast-report.json \
      --report junit=<out>/veridor-junit.xml \
      --report codequality=<out>/gl-code-quality-report.json \
      --report markdown=<out>/veridor-summary.md \
      <extra_args> [--base-url ...] [--baseline ...] [--openapi ...] \
      [--wait-for-target ...] [--require-complete]
    
  8. Prints one line naming the exit code and what it means, then exits with that code.

The job sets interruptible: true and retry: 0: a retried gate reports its luckiest attempt. Nothing in the component uses allow_failure: true, || true or an exit 0 after the gate.

Artifacts and merge request widgets

Under output_dir, uploaded with artifacts: when: always:

File Declared as
gl-sast-report.json artifacts:reports:sast, and a plain artifact
veridor-junit.xml artifacts:reports:junit, and a plain artifact
gl-code-quality-report.json artifacts:reports:codequality, and a plain artifact
veridor-run.json plain artifact
veridor-summary.md plain artifact

What each report contains, and how it shows a non-finding exit, is on reports. Every value of 8 or more characters that Veridor expanded from a ${NAME} is written to them as ${NAME}; a shorter value is written as sent and only named on stderr (safety). The merge request widgets were not observed: no report from this component has been uploaded to GitLab. GitLab's SAST documentation lists the downloadable SAST report on every tier and merge-request findings and vulnerability management on Ultimate only.

Exit codes, and allow_unmeasured

Code Job The job's line
0 passes PASS. Nothing that --fail-on <policy> gates on was found. It adds that a finding the policy does not gate on, such as one in the baseline, also exits 0, and so does an unanswered probe when require_complete is false
1 fails the gate failed on a finding
2 fails refused: configuration, credential, path or coverage, including the job's own refusals above
3 fails; a warning only with allow_unmeasured: true no gating finding, but something went unmeasured
70 fails Veridor crashed, never a finding
130 / 143 fails interrupted; teardown ran first
other fails not a Veridor code

The exit-0 line used to say "Every declared expectation held". The CHANGELOG records that as false: the GitLab job with a baseline holding a suspected finding exited 0 and printed it over a run that recorded 1 vulnerable. See what exit 0 means.

With allow_unmeasured: true the job extends a hidden job carrying allow_failure: exit_codes: [3], so exit 3 and no other code is allowed to fail, unless a rule in job_rules sets allow_failure (above). A component input cannot add or remove a key, so the component renders one hidden job per choice and the gate job extends exactly one. How GitLab displays allow_failure: exit_codes was not observed.

Credentials: fork pipelines and protected variables

Every credential in the configuration is a ${VERIDOR_*} reference. Define each as a masked CI/CD variable of exactly that name. A variable that is unset or set but empty when the job runs makes Veridor stop with exit 2, naming it, before anything is sent:

  • A merge request pipeline from a fork runs in the fork and receives none of the parent project's variables.
  • A protected variable reaches a merge request pipeline only when both the source and target branches are protected (GitLab 18.1+ documentation, not observed).

Both end in exit 2, not in a probe without the credential. GitLab masks a variable only when its value is at least 8 characters (GitLab's documented minimum, not observed here), which is also the shortest value Veridor replaces with its ${NAME} in what it writes and prints.

Masking hides a value in a log. It does not stop a job that can run code from reading it. Use test identities whose tokens can do no harm if they leak.

The non-root image and workspace writability

In the Veridor image the job runs as uid 10001, and it writes its reports into the project directory, so that directory must be writable by uid 10001.

  • GitLab Runner's feature-flag documentation says the docker and kubernetes executors run with umask 0000 by default, which makes the checkout world-writable, and that FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR / FF_DISABLE_UMASK_FOR_KUBERNETES_EXECUTOR replace that with a chown to the image user. Read in the documentation, not observed on a runner.
  • Recorded in the Veridor repository's ci/README.md, with docker standing in for the runner and an image built from the tree: uid 10001 cannot write a checkout root created with umask 0022; a plain veridor run pointed at it exited 2 and sent 0 requests to a counting target, and the same run on a umask-0000 checkout sent 9.
  • The job's own check (step 1) runs first, so for an unwritable output_dir it is the job's message that appears.
  • The shell and the kubernetes executor's behaviour were not tested.

install: pip

For a runner that cannot pull the Veridor image. The job runs in python_image, whose default is python:3.12-alpine pinned by digest (the digest the release Dockerfile builds on; the Veridor test suite holds the two equal), as that image's user (root in python:3.12-alpine). Its before_script runs:

python3 -m pip install --quiet --disable-pip-version-check "<pip_package>"
veridor --version

pip_package defaults to veridor==0.1.0, which is not on PyPI. The name is unclaimed there, so anyone could publish that version, and this default would then run their code with every credential in the job. Until the first release, point pip_package at a wheel committed to, or built earlier in, your pipeline. A requirement pip resolves from an index needs the index reachable from the job, and whoever may set PIP_INDEX_URL chooses what that installs. If you override python_image, pin yours by digest too.

What was tested

"Executed" means it ran and its result was observed. Nothing here ran on GitLab.

What Status
The job as the component renders it: scripts/e2e_gitlab_job.sh renders templates/veridor.yml and runs the job's own before_script and script in the job image with --entrypoint "", against demo targets that count every request Recorded in the Veridor repository's ci/README.md, not re-run for this page: 2026-09-27, an image built from the tree (python:3.12-alpine), 158 checks and 0 mismatches in three runs. The same script and its three controls also ran in docker on a GitHub-hosted runner, in the project's CI job "GitLab component" (run 42508338432, success); that is still not a GitLab runner. Rows: vulnerable → 1, fixed → 0, unreachable → 3, allow_unmeasured → 3 with exit_codes: [3] rendered, install: pip in the digest-pinned default image → 1, wait_for_target: 5 → 0, non-default inputs → 0; refused with exit 2 and 0 requests to the counted target: an empty credential, extra_args: --fail none, a base_url naming an unset variable, a root-owned 0755 checkout; SIGTERM → 143 with the exit line. Rows in the release image and the default python_image ran under busybox sh, which is all those images have; three rows in python:3.12-slim ran under dash and bash, each → 1. Four proxy variables set as pipeline variables → removed, 0 requests to a recording proxy and 9 to the target; with proxy_from_environment: true the proxy received all 9. gl-sast-report.json validated against GitLab's schema 15.2.5 with 0 errors. Controls (report-path-typo, no-term-trap, proxy-kept, an image that rejects --wait-for-target, deleted refusals and flags) each ended in exit 1.
Every veridor command line the component renders, with its defaults and with every input changed, parsed by Veridor's own argument parser; the gate shell run with sh -e against an in-process demo server; the input regexes applied with RE2's $; job_rules modelled over 24 kinds of pipeline Executed for this page on the tree at commit 38025ed: python3.12 -m unittest tests.test_ci_configs, 111 tests, OK (macOS, 2026-11-09).
The spec header, both renders, ci/.gitlab-ci.yml and the README's GitLab YAML against GitLab's CI JSON schema Schema-validated, 0 errors, with controls the schema rejected (recorded in ci/README.md).
Resolving include: remote: or include: component:; what integrity: does on a match or a mismatch; GitLab's own interpolation, including how a boolean inside a string is written; the extends merge; which pipelines the rules actually create; report upload and the merge request widgets; the runner's real cancel path; the kubernetes and shell executors; Catalog publishing Untested: it needs GitLab.

Related: reports, exit codes, other CI systems, install.