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:
- 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>^{}'. - Set
integritytosha256-followed by the base64 SHA-256 of the file at that commit:curl -fsSL "<the URL>" | openssl dgst -sha256 -binary | base64.integrityneeds 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_proxyorhttps_proxy, in any case, would carry every request to a proxy of the caller's choosing; the component removes them unlessproxy_from_environmentistrue(proxies);- with
install: pip,PIP_INDEX_URL(andPIP_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¶
- Checks it can write
output_dir, by creating it and a probe file, and stops with exit2before 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. - Refuses an input that names a variable which expanded to nothing. If
base_url,baselineoropenapiwas given and is empty at run time, the job stops with exit2and says what leaving the input empty would mean (forbase_url: probing the configuration's owntarget.base_url). - Refuses a
wait_for_targetthat is not a whole number of seconds, with exit2. - Checks
extra_argsas above. - Refuses, with exit
2, an image withoutenv,sedorprintenv, which it needs to find the proxy variables; then removes, or keeps, them (proxies). - 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. -
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] -
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 0000by default, which makes the checkout world-writable, and thatFF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR/FF_DISABLE_UMASK_FOR_KUBERNETES_EXECUTORreplace 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 plainveridor runpointed at it exited2and 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_dirit 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:
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.