Skip to content

Troubleshooting

Something went wrong, or a run said less than you expected. This page lists the refusals and errors you are most likely to hit, grouped by what you see, with the line Veridor prints, the cause, and the fix.

Start with two things: the exit code, and the line on stderr that explains it. A refusal made once the command line has been read, and every condition that moves the exit code to 3, prints a line that begins veridor:. A flag value refused while the command line is parsed prints veridor run: error: … (or the name of the verb you ran) after the usage text instead; see a flag value that is refused before anything runs. The exit code says which section of this page to read.

Exit code What it means Read
2 Refused. Usually nothing was sent. Configuration refused
3 No gating finding, but something the gate needs was not measured. Nothing measured
1 with findings you do not believe Every probe was denied, or a redirect or a proxy answered. Every probe denied, Redirects, Proxies
0 with inconclusive findings Some probes went unanswered and --require-complete was off. Rate limits, 5xx and timeouts
70 Veridor crashed. Never a finding. In CI

The full mapping per verb is on exit codes.

How these examples were run

Every output below is real and abridged; … marks a cut. The repeated liveness-controls notice and the Scope: and Pass: lines that close every summary are left out.

The target is the demo API from the repository, started on port 18807:

python3 scripts/demo_server.py --port 18807

veridor.json is fixtures/veridor.demo.json with target.base_url set to http://127.0.0.1:18807. Against that target it prints 6/6 probes matched their declaration and exits 0. Each section names the one change it made to that file.

Configuration refused (exit 2)

Veridor checks the configuration, the flags and every output path before the first request. A refusal exits 2 and, apart from the cases noted on exit codes, nothing reaches the target. The veridor: line names the key, the check or the identity at fault.

Every configuration refusal, with its cause, is in the refusal index. Session refusals have their own index. The ones below are the ones people hit first.

A ${VAR} that is not set, or is set but empty

invoice_owner's header changed to read its token from the environment:

"headers": {"Authorization": "Bearer ${VERIDOR_OWNER_TOKEN}"}
$ veridor run --config env.json
veridor: environment variable 'VERIDOR_OWNER_TOKEN' is not set

$ VERIDOR_OWNER_TOKEN= veridor run --config env.json
veridor: environment variable 'VERIDOR_OWNER_TOKEN' is set but empty. Veridor refuses it rather than send an empty value: a withheld secret (a fork pull request gets an empty string, not an unset variable) would otherwise be probed as a real identity and read as a finding about the target. Set it to the real value, or do not run this job when the secret is unavailable. Nothing has been sent.

Both exit 2. A value that is only whitespace is refused the same way.

Fix: export the variable in the shell or the CI job that runs Veridor. In CI, an empty value usually means the job ran where the secret is withheld; see in CI.

A key Veridor does not recognise

An expectation with exclude_marker where excludes_marker was meant:

"other_tenant": {"status": [404], "exclude_marker": "invoice_owner"}
$ veridor run --config typo.json
veridor: check 'invoice-tenant-isolation' expectation for 'other_tenant' has unsupported keys: exclude_marker (did you mean one of contains_marker, excludes_marker, status?) Rename it, or start it with '_' if it is an annotation.

Exit 2. A near-miss key is refused rather than ignored, because an ignored assertion is one that never runs.

Fix: use the key the message suggests. To keep a note in the file, start its key with _. Which keys are accepted where is on annotations and near-miss keys are refused.

Two identities send the same credential

other_tenant's header changed to Bearer owner-token, the owner's token:

$ veridor run --config dup.json
veridor: identities 'invoice_owner' and 'other_tenant' send identical credentials, so every probe would compare an identity with itself and every expectation between them would pass without testing anything. Give them different credentials, or delete one.

Exit 2. This often means two ${VAR} references expand to the same secret, or an identity block was copied and its token not changed.

Fix: give each identity its own credential. Identities that send no credential at all, such as two anonymous callers, are exempt.

A {id} left in a path

The first check's path changed to /api/invoices/{id}:

$ veridor run --config placeholder.json
veridor: 1 check(s) still contain an unsubstituted path placeholder, so they name no concrete resource:
  invoice-tenant-isolation (/api/invoices/{id})
Veridor sends the path verbatim and never enumerates ids, so each of these would request a literal '{id}' and report the refusal as a finding. Replace each placeholder with a real resource id. If a path genuinely contains braces, percent-encode them as %7B and %7D.

Exit 2. veridor init writes placeholders on purpose; this is the refusal that follows if you run its output unedited.

Fix: replace each placeholder with the id of a real resource the identity owns. Veridor never discovers ids for you.

--read-only refuses a write

A check with "method": "DELETE" added, run with --read-only:

$ veridor run --config writes.json --read-only
veridor: --read-only refuses DELETE in check 'invoice-delete' request

Exit 2. --read-only refuses the whole configuration if any request in it, including a login step, a marker probe, a sequence step or a teardown, uses a method other than GET, HEAD or OPTIONS.

Fix: remove the write from the configuration you run against this target, or drop --read-only for a target you provisioned yourself. See --read-only and writing to the target.

The configuration file is not found

$ veridor run --config missing.json
veridor: configuration not found: missing.json

Exit 2. The path is read relative to the directory you run Veridor from. The demo fixtures are in the repository, not in a wheel, so configuration not found: fixtures/veridor.demo.json from an installed package is a missing checkout, not a broken install; see the quickstart note.

A flag value that is refused before anything runs

Some flag values are refused while the command line is parsed. These exit 2 and print the usage text first:

$ veridor run --config veridor.json --wait-for-target 2s
usage: veridor run [-h] [--config CONFIG] [--output-dir OUTPUT_DIR]
…
veridor run: error: argument --wait-for-target: '2s' is not a whole number of seconds (0 or more); 0 turns the wait off
…

$ veridor run --config veridor.json --report junit=-
usage: veridor run [-h] [--config CONFIG] [--output-dir OUTPUT_DIR]
…
veridor run: error: argument --report: junit=- would interleave the report with the run summary `run` prints on stdout. Write it to a file, or render it afterwards with `veridor report --from-run RUN.json --format junit --output -`
…

Fix: read the veridor run: error: line, which names the flag and the rule. The line after it points at veridor --help. Running veridor with no arguments prints the top-level help and also exits 2.

An output Veridor cannot write

--output-dir under a directory with mode 555:

$ veridor run --config veridor.json --output-dir ro/history
veridor: cannot write the results of `run` to --output-dir ro/history (resolved: …): could not create it (Permission denied) as uid:gid 501:20. Nothing has been sent: this is checked before the first request, because a run whose results cannot be written would still probe the target and then lose what it found. Fix: pass an --output-dir this user can write (…), or, in a container, run it as the owner of the mounted directory (docker run --user "$(id -u):$(id -g)").

Exit 2. The same check covers --json, --sarif, --coverage, --badge and every --report path, in run, map, mutate and verify.

Fix: point the output at a directory the user running Veridor can write. In a container, run as the owner of the mounted workspace, as the message says.

A baseline that is the run's own latest.json

$ veridor run --config veridor.json --baseline .veridor/latest.json
veridor: …/.veridor/latest.json is the file this command writes its own result to (latest.json under --output-dir), so it would be replaced as the command runs. A configuration or specification there is destroyed; a baseline there becomes this run's own result, and the re-run compares a live finding against itself and exits 0. Copy it out first -- `cp …/.veridor/latest.json pinned.json`, then pass pinned.json -- or choose a different --output-dir. Nothing has been sent.

Exit 2. The message prints the resolved path; it is cut here.

Fix: copy latest.json to a file of its own, commit that, and pass it as --baseline. A run under .veridor/runs/ is accepted, because nothing rewrites it.

map: a target that is not loopback or a private address

$ veridor map --config map.json --openapi openapi.json --base-url http://staging.example.com:8080
veridor: staging.example.com:8080 is not a loopback or private address. Pass --acknowledge-target 'I am authorized to test staging.example.com:8080'. This is friction, not a security boundary: the point is that you must type the hostname, so pointing the matrix at the wrong host is hard to do by accident. Nothing has been sent.

Exit 2. map.json is fixtures/veridor.map-demo.json with the same port change, and openapi.json is fixtures/openapi.demo.json. A hostname is never resolved, so every hostname except localhost and *.localhost needs the acknowledgement. run does not ask for one.

Fix: pass --acknowledge-target with the exact string the message prints, if you are authorized to test that host. Other map refusals are on map's safety envelope.

Nothing measured (exit 3)

Exit 3 means the gate found nothing it fails on, and something it needs was not measured. Measured means the authorization layer answered. A 429, a 5xx, a proxy's 407, a transport error, a timeout and a body too large to scan are not answers. Two conditions exit 3 with no flag. A sequence teardown that did not complete cleanly needs --require-clean-teardown; the rest need --require-complete. The full table is on what produces 3.

Not one probe was answered

The demo target stopped, nothing listening on the port:

$ veridor run --config veridor.json
veridor: not one of 6 probe(s) was answered, so this run established nothing. It is not a pass. Check the target URL, the credentials, and that the target is up.
Veridor run …: 0/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 6 inconclusive
Target: http://127.0.0.1:18807
Regression diff: 6 new, 0 resolved, 0 unchanged
  None of those was measured: not one of 6 probe(s) was answered, so every entry above is a probe that did not run, not a finding about your target.
…
- [inconclusive] invoice-tenant-isolation / invoice_owner: Request failed: [Errno 61] Connection refused
    Nothing was listening on target.base_url. Check the host and port, and that the target is up.
…

Exit 3, with no flag. The run document's gate.reasons carries nothing_answered.

Causes: a wrong base_url or --base-url, a target that is not up yet, a target that answers every request with 429 or 5xx, or a timeout shorter than the target needs. Each finding's line names which.

Fix: check the finding lines first; they say whether the connection was refused, timed out, or was answered with a status that is not an answer. For a target that is still starting, see --wait-for-target.

When the configuration itself withheld every request, the same line ends differently: it says nothing was sent, names the reasons, and says it is not a network problem. Fix the configuration as each finding's remediation says.

Rate limits, 5xx and timeouts

run never retries, and it does not stop early on a rate limit: each probe that draws a 429 or a 5xx is graded inconclusive. A target that answers every request with 429, from the repository's noisy demo server:

python3 scripts/noisy_server.py --port 18807 --rate-limit 1.0 --server-error 0 --slow 0
$ veridor run --config veridor.json
veridor: not one of 6 probe(s) was answered, so this run established nothing. It is not a pass. Check the target URL, the credentials, and that the target is up.
…
- [inconclusive] invoice-tenant-isolation / invoice_owner: Identity 'invoice_owner' received HTTP 429, which is not an answer from the authorization layer: the request was throttled or the handler failed. This probe establishes nothing about the declared status [200], in either direction.
    Re-run when the target is healthy. Until then this boundary is unverified, not verified.
…

Exit 3, because nothing was answered.

A target where only some requests fail is the case to watch. With --server-error 0.3 and the server's other noise turned off, about three requests in ten draw 503:

python3 scripts/noisy_server.py --port 18807 --rate-limit 0 --server-error 0.3 --slow 0
$ veridor run --config veridor.json
Veridor run …: 4/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 2 inconclusive
…
$ echo $?
0

$ veridor run --config veridor.json --require-complete
veridor: 3 probe(s) were never answered, so those boundaries are unverified rather than verified. Re-run when the target is healthy.
Veridor run …: 3/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 3 inconclusive
…
$ echo $?
3

Without --require-complete, a run in which some probes were answered exits 0 and lists the rest as inconclusive. Those boundaries were not tested. The two runs above, against the same server, left different numbers of probes unanswered, because its failures are random.

A timeout, with target.timeout_seconds set to 1 and a server that takes 2.5 seconds to answer every request:

python3 scripts/noisy_server.py --port 18807 --rate-limit 0 --server-error 0 --slow 1.0

The noisy server's defaults also answer about 6% of requests with 429 and 3% with 503, so each flag is set explicitly in these runs.

$ veridor run --config short-timeout.json
veridor: not one of 6 probe(s) was answered, so this run established nothing. It is not a pass. Check the target URL, the credentials, and that the target is up.
…
- [inconclusive] invoice-tenant-isolation / invoice_owner: Request failed: timed out waiting for a response: a TCP connection was established and the request written, but no response arrived within 1s
    A TCP connection was established and the request written, but no response arrived within target.timeout_seconds (1s). Whatever accepted the connection -- the target, or a proxy or sidecar in front of it -- did not answer in time. If the target is simply slow, raise that value and re-run.

Exit 3.

Fix: pass --require-complete in CI so a partial run fails rather than passes; the GitHub Action, the GitLab component and the Kubernetes packagings turn it on by default. Then lower the load Veridor competes with, or raise target.timeout_seconds for a slow target. It must be greater than zero and no more than 60; a larger value is refused with target.timeout_seconds must be a number greater than zero and no more than 60s. If the target's rate limiter is the cause, run the gate where the limit is higher, such as a staging environment with relaxed limits.

What --require-complete counts

--require-complete exits 3 for three conditions: a probe graded inconclusive, an expectation that accepts both access and denial, and an unproven denial. Each prints its own line.

The first line reads N probe(s) were never answered, and it counts every inconclusive finding, including those where the target did answer but a marker could not be verified (see a marker that is never seen). The finding lines below it say which it was. Do not read Re-run when the target is healthy as the diagnosis until you have read them.

An expectation that accepts both a success and a denial, here "status": [200, 403] for invoice_owner on the audit check:

$ veridor run --config loose.json --require-complete
veridor: 1 expectation(s) accept both access and denial, so they assert nothing about the boundary and their pass is not evidence. Narrow them to the statuses you require.
…
$ echo $?
3

Fix: declare the one outcome you require for that identity.

--fail-on none still reports

--fail-on none stops findings, nothing_answered and the identity and liveness conditions from moving the exit code. It does not switch off --require-complete. Measured with nothing listening:

$ veridor run --config veridor.json --fail-on none
veridor: not one of 6 probe(s) was answered, so this run established nothing. It is not a pass. Check the target URL, the credentials, and that the target is up.
…
$ echo $?
0

$ veridor run --config veridor.json --fail-on none --require-complete
…
$ echo $?
3

The line still prints, and the run document lists the condition under gate.not_enforced. See --fail-on none is not "always zero".

Every probe denied

A run where the identities you expect to be served are refused is most often a credential problem, not a boundary problem.

A credential that expired or was mistyped

An identity that should be served. invoice_owner's token changed to Bearer owner-token-expired:

$ veridor run --config expired-owner.json
Veridor run …: 4/6 probes matched their declaration, 0 vulnerable, 1 unexpected denial, 1 inconclusive
…
- [unexpected_denial] invoice-tenant-isolation / invoice_owner: Identity 'invoice_owner' received HTTP 404 (declared: one of [200]). The target refused this request, so nothing was returned to this identity on this probe.
    Confirm the declared status is the outcome you intend for this identity. If it is, investigate why the target refused this request -- an absent route, an unshipped feature, or an over-restriction. If the declaration is wrong, correct the configuration instead.
- [inconclusive] invoice-tenant-isolation / other_tenant: Identity 'other_tenant' received the declared HTTP 404, but the absence of 'invoice_owner''s marker could not be verified (the control probe did not observe its own marker).
…
$ echo $?
1

Exit 1: an unexpected_denial is a finding. When it appears for an identity on requests it is normally served, and especially for every identity at once, check the credentials before the target.

An identity that is only ever denied. other_tenant's token changed to Bearer outsider-token-expired. Its declarations expect only 404 and 403, and a dead token draws exactly those:

$ veridor run --config expired-outsider.json
Veridor run …: 6/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18807
Regression diff: 0 new, 0 resolved, 0 unchanged
Unproven denials: 2 expectation(s) held against a denial that a credential Veridor fabricated received too, so they did not establish who was denied.
  - invoice-tenant-isolation / other_tenant: 'other_tenant' received HTTP 404 on this request, and so did a credential Veridor fabricated that the target cannot have recognised. …
…
$ echo $?
0

6/6 probes matched, exit 0. The Unproven denials line is the only sign the attacker identity's token is dead. With --require-complete:

$ veridor run --config expired-outsider.json --require-complete
veridor: 2 expectation(s) were satisfied by a denial that a credential Veridor fabricated received too ('other_tenant'). Those boundaries are unverified rather than verified, and this is deterministic -- a re-run reports identically. Give the identity a marker_probe naming something it should be served, or accept that a target which hides existence uniformly cannot be measured from outside.
…
$ echo $?
3

The run document records the reason as liveness_unproven.

Fix: renew the credential. To make a dead credential fail the gate, give every identity that is only ever denied a marker_probe: a request it should be served, issued once per run. In this example other_tenant already has one, which is why the remediation line does not help: its token is dead, so the probe failed too. The run document's marker_controls entry for that identity then has "state": "unverified" and a reason such as the marker probe for 'other_tenant' returned HTTP 404 without the marker, so the marker is not known to be live. Read that entry when the Unproven denials line names an identity that has a marker_probe.

The liveness controls

The fabricated credential in those lines is Veridor's own: a veridor-sham-… value that authenticates to nothing, sent once per GET, HEAD or OPTIONS request whose declaration accepts only denial statuses. It is also sent on such a request that excludes an owner's marker, and on one that serves an identity whose credential must be proven live, because a 2xx proves a credential live only where the fabricated one is refused. The count is printed before the first one is sent. If your target answers your identity exactly as it answers that credential, the denial established nothing.

--no-prove-liveness skips those requests, and the unproven denials are then not reported at all. Leave it on unless the extra requests are a problem for the target. See the liveness controls.

A session that never logs in

For an identity with a session whose login fails for a reason that is not transient, such as a wrong password, every probe naming it is inconclusive and no request is sent as that identity. The run exits 3 with no flag, gate reason identity_unauthenticated, and this line:

veridor: identity 'X' never authenticated, so no boundary involving it was tested. This failure is deterministic: a re-run will fail identically. Check the credentials.

A 408, 425, 429 or 5xx on the login step, or a transport error during it, counts as transient and does not trigger it. See what happens when a session does not establish.

A marker that is never seen

invoice_owner's marker changed to a fresh value from veridor new-marker that was never planted in the owner's data:

$ veridor run --config nomarker.json
Veridor run …: 4/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 2 inconclusive
…
- [inconclusive] invoice-tenant-isolation / invoice_owner: Identity 'invoice_owner' received the declared HTTP 200, but 'invoice_owner''s marker was not observed (response did not contain it); exclusion results for this check are unverified.
    Confirm the marker is stored in the identity's data and returned by this endpoint, then re-run.
- [inconclusive] invoice-tenant-isolation / other_tenant: Identity 'other_tenant' received the declared HTTP 404, but the absence of 'invoice_owner''s marker could not be verified (the control probe did not observe its own marker).
    Confirm the marker is stored in the identity's data and returned by this endpoint, then re-run.
$ echo $?
0

Exit 0 by default and 3 with --require-complete. The owner was served, but its marker was not in the response, so an excludes_marker on another identity cannot be graded: an absence proves nothing when the presence was never seen.

Causes: the marker is not stored in that identity's data, the endpoint does not return the field it is stored in, the value in the configuration differs from the planted one, or the response was too large to scan to the end.

Fix: plant the exact string from the configuration in a field the endpoint returns, and confirm it by requesting that endpoint as the owner. A body that was not fully read reports unknown with a reason, never absent. See marker.

Redirects are not followed

Veridor never follows a redirect and never forwards credentials to the new location. A 3xx is recorded as the answer. For a session application, a 302 to a login page is its ordinary denial; following it would turn that denial into a 200.

Against a listener that answers every request with 302 to /login (a few lines of Python, not part of the repository):

$ veridor run --config veridor.json
Veridor run …: 0/6 probes matched their declaration, 0 vulnerable, 6 unexpected denial, 0 inconclusive
…
- [unexpected_denial] invoice-tenant-isolation / invoice_owner: Identity 'invoice_owner' received HTTP 302 (declared: one of [200]). The target refused this request, so nothing was returned to this identity on this probe.
…
$ echo $?
1

run grades a 302 as not served, so where you declared 200 it is an unexpected_denial. map records a redirect as answered but not observed, and a grid of nothing but redirects exits 3:

$ veridor map --config map.json --openapi openapi.json
veridor: not one of 12 probed cell(s) was measured, so this matrix records nothing. It is not a baseline and it is not a pass. 12 of them were answered with a redirect, which Veridor does not follow, so nothing behind them was observed: a login wall, an SSO proxy, a maintenance page, or http:// for an https-only host are the usual causes. Check the target URL, the credentials, and that the target is up.
…
$ echo $?
3

Fix: use the URL the redirect points to as base_url, most often https:// where you wrote http://, or a path prefix the target adds. For a login wall, give the identity a session so it logs in. If the redirect is the denial you mean, declare its status, such as [302], for the identity that should be refused.

Proxies

Veridor reads http_proxy and https_proxy, in either case, and no_proxy from the environment, and never the operating system's proxy settings. A loopback target is always reached directly; with the demo on 127.0.0.1 and http_proxy set, no notice is printed and the run exits 0.

For any other target, a proxy in use is announced before the first request:

$ http_proxy=http://127.0.0.1:18807 veridor run --config veridor.json --base-url http://staging.invalid:8080
veridor: requests to staging.invalid:8080 go through the proxy 127.0.0.1:18807 named by http_proxy, not straight to the target: the target is http://, so it sees every request in full, credentials included. Set NO_PROXY=staging.invalid to connect directly.
Veridor run …: 0/6 probes matched their declaration, 0 vulnerable, 5 unexpected denial, 1 inconclusive
…

In that run the "proxy" was the demo server, which does not proxy, so every answer came from it, not from staging.invalid. That is the general lesson: when this line appears, the findings describe whatever the proxy returned.

Two proxy values are refused with exit 2, before anything is sent, and the value is never printed:

$ http_proxy=http:/proxy veridor run --config veridor.json --base-url http://staging.invalid:8080
veridor: http_proxy is set, and urllib cannot read a proxy host from it (a proxy is written http://host:port, or http://user:password@host:port with any '@', ':' or '/' in the user or password percent-encoded). Its value is not printed. Fix or unset http_proxy, or set NO_PROXY=staging.invalid to connect directly. Nothing has been sent.

$ https_proxy=https://127.0.0.1:18807 veridor run --config veridor.json --base-url https://staging.invalid
veridor: https_proxy names an https:// proxy for an https:// target. urllib opens a tunnel to a proxy over plain TCP, so the CONNECT line and any user:password in https_proxy would cross the network in plaintext, whatever the scheme says. Write the proxy as http://host:port if it accepts that, or set NO_PROXY=staging.invalid to connect directly. Its value is not printed. Nothing has been sent.

A 407 Proxy Authentication Required is the proxy's answer, not the target's. run grades it inconclusive, so a run that drew nothing else exits 3.

Fix: set NO_PROXY to the target's host, as the notice says, to connect directly; or correct the proxy value. no_proxy follows urllib's rules: host names, domain suffixes and *, not CIDR ranges or *.example.com. The full rules are on proxies.

Rate limits during map

map probes a whole grid, so it watches for signs it is not welcome and stops the grid when it sees them: three consecutive 429s, five in total, five consecutive 5xx, ten consecutive transport failures, or 429 and 5xx making up 20% of at least 20 requests. The thresholds are on safety posture.

Against the noisy server answering every request with 429 and Retry-After:

$ veridor map --config map.json --openapi openapi.json
veridor: not one of 12 probed cell(s) was measured, so this matrix records nothing. It is not a baseline and it is not a pass. Check the target URL, the credentials, and that the target is up.
Veridor access matrix -- this will issue 12 requests to 127.0.0.1:18807.
…
  rate:       2 req/s, sequential
…
INCOMPLETE -- this run aborted after 3 of 12 requests (retry_after_requested). The reach numbers below are a floor over a partial matrix.
…
$ echo $?
3

The cells it never reached are recorded as unknown, not guessed. A 429 with Retry-After counts toward the same budget as one without, and the abort reason is retry_after_requested when the header was on the last one.

run has no such stop. Against the same server it sent every probe and graded each inconclusive (shown above).

Fix: lower --rate (requests per second, default 2), narrow the grid with --include or --ignore, or run against an environment with a higher limit. --on-retry-after stop abandons the grid on the first 429 that carries Retry-After, if you prefer to stop at once.

The target is not up yet

In CI the gate often starts while the target is still starting. Without a wait the probes are refused and the run exits 3, as above. --wait-for-target SECONDS (on run and map) tries a plain TCP connection once a second before the first request, and then runs either way.

Nothing listening on the port:

$ veridor run --config veridor.json --wait-for-target 2
veridor: target 127.0.0.1:18807 did not accept a connection within 2s (--wait-for-target, 3 attempt(s); last: [Errno 61] Connection refused); running anyway, probes will record their own transport errors.
…
$ echo $?
3

The demo server started four seconds after Veridor:

$ veridor run --config veridor.json --wait-for-target 30
veridor: target 127.0.0.1:18807 accepted a connection after 4s (--wait-for-target 30, 5 attempt(s)).
Veridor run …: 6/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
…
$ echo $?
0

The wait never changes a verdict. An accepted connection means a port is open, not that the application behind it is ready, and not that it is the version you meant to gate: while a previous version's pods are still routed, one of them accepts at once. The value must be a whole number of seconds; 0 (the default on the command line) turns it off.

Fix: set --wait-for-target comfortably longer than the target's start time and shorter than the job's own deadline. For Kubernetes and Argo CD, see waiting for the target and aim the gate at the new pods.

In CI

Fork pull requests and withheld secrets. A pipeline that cannot see a secret gets an empty string, and Veridor refuses it with is set but empty, exit 2. Do not run the gate for jobs where the secret is withheld. See GitLab credentials and GitHub Action credentials.

Exit 3 in CI, exit 0 on your laptop. The GitHub Action, the GitLab component and the Kubernetes packagings pass --require-complete by default; the command line does not. Re-run locally with --require-complete to see the same result.

A container that cannot write the workspace. The image runs as a non-root user. A mounted workspace owned by another user draws the cannot write the results refusal before the first request. Run the container as the workspace owner, or point the outputs at a directory that user can write. See the non-root image and workspace writability.

A proxy you did not set. In CI, whoever can add a proxy variable to the job's environment decides where every credential goes. If the proxy notice appears and you did not expect it, find out who set the variable. The GitLab component removes proxy variables unless proxy_from_environment is true; the other templates do not. See who may set what.

Retries. Do not retry exit 1: a retried gate reports its luckiest attempt. Exit 2 needs the cause fixed first. Exit 3 may measure on a re-run, but read why first. The full guide is what a pipeline should do with each code.

Exit 70. Veridor crashed. The traceback goes to stderr, followed by veridor: internal error (<Type>). This is a bug in Veridor, not a finding about your target, and the command did not finish. …. It is never a finding about the target; keep the traceback for the report.

Exit 130 and 143. The run was interrupted by SIGINT or SIGTERM, for example a cancelled job or a pod termination. Teardown for the sequence in flight runs first, and a run stopped before its probes finish writes no run document. Nothing was concluded; a re-run is safe.