Skip to content

Configuration reference

A Veridor configuration declares what each identity is allowed to do. Veridor consumes that declaration and reports where the target disagreed with it. It never invents policy, and a wrong declaration is indistinguishable from a real finding — which is why this file is the thing you review, not the output.

The format is JSON. There is no YAML loader, no include mechanism, and no templating beyond ${ENV_VAR} substitution and, inside a sequence, a single-assignment capture map. Everything below is validated at load time, before a socket opens. Every failure in this page exits 2; see exit codes.

A minimal configuration

This is the smallest file veridor run accepts:

{
  "version": 1,
  "target": {
    "base_url": "https://staging.example.com"
  },
  "identities": {
    "owner": {"headers": {"Authorization": "Bearer ${OWNER_TOKEN}"}},
    "other_tenant": {"headers": {"Authorization": "Bearer ${OTHER_TENANT_TOKEN}"}}
  },
  "checks": [
    {
      "id": "invoice-tenant-isolation",
      "request": {"method": "GET", "path": "/api/invoices/inv-100"},
      "expect": {
        "owner": {"status": [200]},
        "other_tenant": {"status": [403, 404]}
      }
    }
  ]
}

Three things are load-bearing and none of them can be dropped. There are at least two identities, because one identity cannot express a cross-identity boundary and a boundary is the only thing this tool measures. The check names at least two identities in expect, for the same reason. And each expectation carries a status list, because a probe with nothing declared about it grades nothing.

Note

{"status": [403, 404]} accepts two denial codes. That is a narrower declaration than it looks, and a wider one than [404]. An expectation that accepts both an access status and a denial status asserts nothing and is reported as non-asserting — see verdicts.

Top-level keys

Key Type Required Read by
version integer, 1 or 2 yes every command
target object yes every command that sends a request
identities object, at least 2 entries yes every command
checks array yes for run, mutate, coverage; optional for map run, mutate, coverage
sequences array no; requires "version": 2 run
matrix object no map only, ignored by run
coverage object no coverage, run --openapi and map; never the gate
$schema string no nothing in the tool; editors only

Any other top-level key is accepted and ignored, unless it is a near-miss of one of these (Checks, Sequences, chekcs), which is refused: a slip in the name would drop everything under it without a word. veridor init writes _comment and _generated_by itself, so an unrecognised key has to be an annotation rather than an error, and a key starting with _ is never judged.

coverage holds denial_statuses and _ annotations and nothing else. It replaces the statuses boundary coverage counts as an authorization denial (403 and 404 by default) and accepts only 4xx statuses other than 401 and 407. A status outside that rule, a null, an unknown key, or a coverage that is not an object is refused before the figure is computed: veridor coverage exits 2. run --openapi sends its probes first, then reports coverage as not measured and exits 2; map sends its requests, leaves the coverage section out and keeps its own exit code.

$schema is one of those ignored keys. Adding it gives you completion and inline validation in any editor with a JSON language server — see the JSON Schema, which also records the places where that schema and the loader currently disagree.

version

{"version": 1}

1 and 2 are both accepted. 2 adds sequences and nothing else: a version-2 config with no sequences key produces the same run document, the same fingerprints and the same exit code as the same file at version 1.

Declaring sequences at version 1 is refused:

sequences requires configuration version 2

That gate exists because an older installed veridor ignores unknown top-level keys. Without it, a version-1 config carrying sequences would run on the older binary, silently execute only the checks, and report a green run with the multi-step tests dropped. The gate makes the older binary fail loudly instead.

Any other value, including true, an object or a list, is refused with exit 2:

configuration must be an object with version 1, 2 (2 adds `sequences`)

"version": true used to compare equal to 1 and load as version 1, and an object or a list crashed the loader with exit 70, "a bug in Veridor", for what is a malformed input. Measured against commit 38025ed: "version": true is refused with the message above.

target

"target": {
  "base_url": "https://staging.example.com",
  "timeout_seconds": 10,
  "teardown_timeout_seconds": 5
}
Field Type Required Default What it does
base_url string yes — Root of the system under test
timeout_seconds number, 0 < n ≤ 60 no 10 Per-request timeout
teardown_timeout_seconds number > 0 no timeout_seconds Separate budget for sequence teardown

base_url must be http:// or https:// with a host, and may carry a path prefix (https://api.example.com/v1). It may not carry a query string, a fragment, or credentials in the host, and it may not contain whitespace or control characters, C1 and Unicode format controls (such as U+202E) included.

Requests stay under it. Every request is checked just before it is sent against the base URL's scheme, host and port, and, when the base URL has a path, against that path: with https://gateway/service-a/, only /service-a and what is under it. A base URL with no path contains requests to its origin and nothing narrower. run and verify used to contain requests to the origin only, so a .. segment could reach another application behind the same gateway; the path rules under request close that at load time.

What you wrote Refusal
file:///etc/passwd target.base_url must be an http(s) URL with a host
http://u:p@host target.base_url must not contain credentials
http://host?a=b target.base_url must not contain a query or fragment
missing target.base_url must be an http(s) URL
target not an object target must be an object

--base-url on the command line replaces base_url and is validated the same way.

timeout_seconds is capped at 60. The cap is not tuning:

target.timeout_seconds must be a number greater than zero and no more than 60s.
A per-request timeout with no ceiling makes a run's duration unbounded.

NaN and the infinities are refused here too, because NaN <= 0 is false and the value would otherwise reach the socket layer as a crash rather than a refusal.

Unknown keys under target are accepted and ignored.

identities

An identity is a named caller. At least two are required:

identities must define at least two users

Each name must match ^[A-Za-z0-9._-]+$ — see names are constrained below.

An identity object accepts exactly four keys, and must declare at least one of them:

Key Type What it does
headers object of string → string Static credentials sent on every request as this identity
marker string A unique value planted in this identity's data
marker_probe request object A request this identity should be served, used to prove its marker is live
session object Cookie login. See sessions and CSRF

headers

"owner": {"headers": {"Authorization": "Bearer ${OWNER_TOKEN}"}}

Header names must be HTTP tokens. Values may not contain control characters, and may not contain code points above U+00FF — http.client encodes header values as latin-1, so anything higher would crash inside the opener rather than be refused.

An identity that is meant to send no credentials declares that explicitly:

"anonymous": {"headers": {}}

Writing credentials one level too high is refused, and this is the refusal worth knowing about:

{"owner": {"Authorization": "Bearer ${OWNER_TOKEN}"}}
identity 'owner' defines none of headers, marker, marker_probe, session — only
Authorization. Did you mean {"owner": {"headers": {"Authorization": ...}}}? As
written this identity sends no credentials, so it would be probed anonymously
under its own name. Declare "headers": {} if it is genuinely meant to be
anonymous.

As written, that identity authenticates with nothing, so its reach would be recorded under its own name while anonymous was doing the reaching. That is the one direction this tool must not be wrong in.

Two identities that send the same credential are also refused:

identities '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.

The comparison covers Authorization, Cookie, X-API-Key, X-Auth-Token, API-Key and Token, case-insensitively. Identities that deliberately send nothing are exempt: more than one anonymous caller is a legitimate declaration.

marker

A status code alone cannot tell you whether data moved. A marker closes the data-flow gap and only that gap.

veridor new-marker
vdr-EuHSKZURhbkafLHayRObGLF5

Plant that string somewhere in the identity's own data that the API echoes back, then declare it:

"invoice_owner": {
  "headers": {"Authorization": "Bearer ${OWNER_TOKEN}"},
  "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
}

The validation fails closed, with no warn-and-continue mode and no override, because a weak marker manufactures false confirmations:

Rule Refusal
24–128 characters must be between 24 and 128 characters, got 9
A–Z a–z 0–9 _ - only must use only the characters A-Z a-z 0-9 _ -
at least 10 distinct characters must contain at least 10 distinct characters
no character above 40% of length must not repeat one character more than 40% of its length
no run of more than 4 identical characters must not repeat a character more than 4 times in a row
not a repeated pattern must not repeat a N-character pattern

Two identities may not share a marker, and one marker may not be a substring of another, because a match has to identify exactly one owner.

Veridor also refuses to load a configuration that would send a marker it later matches:

identity 'invoice_owner' marker appears in the request sent by check
'invoice-tenant-isolation'; a reflected marker cannot prove a leak

The check runs over the method, the base URL, the path, header names and values, and the body — and over what a target would see after percent-decoding, HTML unescaping, \uXXXX unescaping, base64 and hex decoding, to a bounded depth. A marker hidden as %76dr-… is caught by the same message.

marker_probe

"other_tenant": {
  "headers": {"Authorization": "Bearer ${OTHER_TENANT_TOKEN}"},
  "marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
  "marker_probe": {"method": "GET", "path": "/api/invoices/inv-200"}
}

A request this identity should be served, issued once per run before the checks. It proves that this identity's marker is live and that its credential is real. It is the control for APIs where the resource path embeds the caller's own principal, so the marker owner and the caller under test can never share one URL and a same-check control is impossible.

A marker_probe on an identity that declares no marker is refused: it would prove nothing.

identity 'administrator' marker_probe names an identity that defines no 'marker'
field; a marker probe exists to prove that identity's marker is live.

The probe request is held to the same rules as a check request, including the reflection guard.

session

For cookie-authenticated targets. It has its own page: sessions and CSRF.

Two rules belong here because they are identity-level:

  • An identity may not declare both a static Cookie header and a session. add_cookie_header does nothing when a Cookie header is already present, so the session cookie would vanish with no diagnostic.
  • veridor map refuses a session-backed identity outright. map builds every probe from static headers and does not log in, so it would probe that identity anonymously and record the anonymous reach under its name. Use run, which does log in.

checks

A check sends one request under every identity its expect names. It is a fan-out, not a script.

{
  "id": "invoice-tenant-isolation",
  "name": "Invoice tenant isolation",
  "request": {"method": "GET", "path": "/api/invoices/inv-100"},
  "expect": {
    "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
    "other_tenant": {"status": [404], "excludes_marker": "invoice_owner"},
    "anonymous": {"status": [401]}
  }
}
Field Type Required Notes
id string matching ^[A-Za-z0-9._-]+$ yes Unique. It is a fingerprint input
name string no Free text, printed in reports and in the viewer
description string no Accepted; no command reads it
request object yes See below
expect object, at least 2 entries yes One entry per identity

checks must be a non-empty list for run, mutate and coverage. veridor map is the one verb that accepts a config with no checks at all, because discovery evaluates no expectation — demanding a checks list before Veridor will look at an API demands the policy the user came here without. A checkless config run through coverage gets veridor: checks must be a non-empty list; the same config under map reports boundary coverage as undefined rather than 0%.

Duplicate ids are refused (duplicate check id: …), and the id space is shared with sequences.

request

Field Type Required Default
method GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE no GET
path string yes —
headers object of string → string no {}
json any no —
body string no —

Those five keys and nothing else. Any other key in a request block (a check's request, a marker_probe, a session login step, a sequence step's request, a teardown entry's request) is refused with exit 2, unless it starts with _, which marks an annotation such as _comment. A near-miss is named with the real key; anything else gets what to write instead:

check 'invoice-tenant-isolation' request has unsupported keys: params (put
'params' in 'path' as a query string, e.g. '/items?tenant=acme') Veridor reads
body, headers, json, method, path here and nothing else, so what an unknown
key holds would never be sent. Nothing has been sent.

Before, only near-misses were refused: params, query, a bare Authorization, description and a capitalised Headers loaded, and the request went out without them. The CHANGELOG records nine requests going out with none carrying the value, while the schema refused all four. Measured against commit 38025ed: params and Headers in a check's request are refused by the loader and by the schema alike.

GET, HEAD and OPTIONS are the safe methods. Anything else is a write, and --read-only refuses the whole configuration before a single request is sent — see safety posture.

method must be upper case. Lower case is refused rather than silently normalised:

check 'c1' request.method must be upper case: write 'GET', not 'get'. HTTP
methods are case-sensitive, and Veridor's safety gates match on the upper-case
form.

The gate evaluates method.upper(), so delete would be correctly refused by --read-only while the request went out as delete. A target that routes case-insensitively would then act on a verb the gate never saw.

path must be a same-origin absolute path: it starts with /, is not //…, carries no scheme, host or fragment, is ASCII, and contains no whitespace or control characters. Otherwise:

check 'c1' request.path must be a same-origin absolute path

A path that becomes an absolute URL once its leading / is dropped to join it onto base_url, such as /http://host/x, is refused with a longer form of the same message: it used to send every identity's credentials to host. If a segment before the first / really contains :, percent-encode it as %3A.

No . or .. segment, in any spelling a server on the way might decode:

check 'c1' request.path must not contain a '.' or '..' segment: plain,
percent-encoded any number of times or as overlong UTF-8 ('%c0%ae' is '.' to a
lenient decoder), before a ';' path parameter ('..;' is '..' to a servlet
container), or next to an encoded '/' or '\' ('..%2f' is '../' to a gateway
that decodes it); a segment still decoding after eight rounds is refused as
well. ... Nothing has been sent.

Joined onto https://gateway/service-a/, /../service-b/admin used to be sent to /service-b/admin, and /..%2fservice-b/admin reached service-b through an nginx that decodes %2f (both recorded in the Veridor CHANGELOG). An encoded / inside a name (group%2Fproject) is kept. Spellings only one server family reads, such as IIS's %u002e or a segment with trailing spaces, are not judged. The same rule applies to marker probes, session logins, sequence steps (again at run time, after captures are substituted), map specimens and capsules.

No Host header. A Host header, in any case, is refused in every header map: identities, checks, marker probes, login steps, sequence steps, teardown, and as a session inject target:

check 'c1' request.headers sets a Host header ('Host'). urllib sends it in
place of the host in the URL, and a gateway that routes by Host delivers the
request, with the identity's credentials, to whichever application that name
selects, while every check Veridor makes on where a request goes reads only the
URL. Put the host in target.base_url (or pass --base-url) and remove the
header. Nothing has been sent.

json and body are mutually exclusive (… cannot define both json and body), and body must be a string.

A configured Accept-Encoding is accepted and then ignored: every request is sent with Accept-Encoding: identity, Cache-Control: no-cache and Pragma: no-cache, unconditionally. Redirects are never followed — a 3xx is an observed response, and credentials are never forwarded to a new host.

An unsubstituted {id} in a path is refused by the verbs that send it:

1 check(s) still contain an unsubstituted path placeholder, so they name no
concrete resource:
  c1 (/api/x/{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.

Every offender is listed in one pass. veridor coverage reads the same file statically and reports a templated path as a templated_check_path diagnostic with zero credit rather than refusing, and veridor init writes placeholders deliberately.

expect

One entry per identity, at least two entries. Every entry is an object with a status list and optionally one marker assertion.

Field Type Required What it asserts
status array of integers 100–599 yes The statuses you accept
contains_marker identity name no This response must carry that identity's marker
excludes_marker identity name no This response must not carry it

A bare integer is accepted and normalised: "status": 200 becomes "status": [200]. Anything else:

check 'c1' expectation for 'owner' requires HTTP status values

contains_marker and excludes_marker name the owner, never the value. They cannot both appear on one expectation, and an expectation may not exclude its own marker.

Narrow the status list to what you actually require. [200, 403, 404] cannot tell allowed from denied, and an expectation that accepts both is reported as non-asserting rather than counted as a pass.

Every exclusion requires a control. Absence of a marker is only meaningful if presence was demonstrable, so a bare excludes_marker is refused:

check 'c1' expectation for 'other' excludes_marker 'owner' requires a control in
the same check; add {"owner": {"contains_marker": "owner", "status": [200]}} to
check 'c1' expect, or give identity 'owner' a 'marker_probe' naming a path where
its own marker is served

Two controls satisfy it, and they are not equivalent — the same-check contains_marker is the stronger one. See verdicts and evidence grades.

Near-miss keys are refused

An unrecognised key at the top level, inside an expectation, an identity or a session is normally an annotation and is ignored (a request block is stricter: see request). A key that is a near-miss of a real one is not, because it would fail silently; two swapped letters count:

check 'c1' expectation for 'owner' has unsupported keys: contain_marker (did you
mean one of contains_marker, excludes_marker, status?)

A typo'd contain_marker would otherwise disable a security oracle and leave a visibly clean run behind it.

The loader does not look for near-misses in target, in a check's own keys or in a teardown entry's expect: there a misspelt optional key (timeout_secnods, nmae) loads as an annotation and its setting is dropped. A key starting with _ is never judged anywhere.

sequences

Some authorization bugs need state that only exists between requests. A sequence is the ordered form: a list of requests, each issued as exactly one named identity, each declaring a status expectation, each optionally naming values to carry forward. sequences requires "version": 2 and is a sibling of checks; a config may declare either or both.

{
  "version": 2,
  "sequences": [
    {
      "id": "invoice-cross-tenant-delete",
      "steps": [
        {"role": "setup", "as": "owner",
         "request": {"method": "POST", "path": "/api/invoices",
                     "json": {"note": "${marker:owner}"}},
         "expect": {"status": [201]},
         "capture": {"invoice_id": "$.id"}},

        {"role": "assert", "as": "attacker",
         "request": {"method": "DELETE", "path": "/api/invoices/${invoice_id}"},
         "expect": {"status": [403, 404]}},

        {"role": "assert", "as": "owner",
         "request": {"method": "GET", "path": "/api/invoices/${invoice_id}"},
         "expect": {"status": [200], "contains_marker": "owner"}}
      ],
      "teardown": [
        {"as": "owner",
         "request": {"method": "DELETE", "path": "/api/invoices/${invoice_id}"},
         "expect": {"status": [204, 404]}}
      ]
    }
  ]
}
Level Keys Notes
sequence id, name, steps, teardown id shares the check id space
step role, as, request, expect, capture, name role is setup or assert, required with no default
teardown entry as, request, expect, name no role, no capture, no marker assertion

role has no default because both defaults are wrong. Defaulting to setup turns a forgotten role on the attacker probe into scaffolding — a green run with the boundary never tested. Defaulting to assert reports a 500 on a provisioning POST as vulnerable.

The structural caps are load-time refusals with no override flag, on the ground that a config nobody can review is not a security tool:

Cap Limit
sequences per config 64
steps per sequence 12
teardown entries per sequence 6
captures per step 8
interpolation sites per step 16
captured value length 512 characters

There are no conditionals, no loops, no arithmetic, no expressions, no transforms and no retries. The only state a sequence carries is a flat, single-assignment map of names to strings and integers, which never touches disk. ${run_id} is reserved and can never be a capture name. ${marker:owner} is the only sanctioned way to plant a marker, and it may appear only in a request body — a marker literal anywhere in a sequence is still a load error.

Danger

Sequences write to the target. Point them only at an environment you can recreate. --read-only refuses the whole configuration, before any request, if any declaration uses a method outside GET, HEAD and OPTIONS:

--read-only refuses POST in sequence 'invoice-cross-tenant-delete' step 0,
and 2 other mutating declaration(s). All 3 are listed below (verbs: DELETE,
POST); nothing has been sent, and together they declare 3 request(s) -- more
under `mutate`, which re-issues each one per probe:

Every offender is listed in one pass, up to 50, and the count of declared requests is a lower bound for mutate, which re-issues each declaration per probe.

matrix

Settings for veridor map. run ignores this section entirely.

"matrix": {
  "specimens": {
    "/api/invoices/{id}": [{"label": "owner_invoice", "values": {"id": "inv-100"}}]
  },
  "marker_owner": "invoice_owner",
  "peer_groups": {"tenants": ["invoice_owner", "other_tenant"]},
  "privilege_order": ["anonymous", "other_tenant", "administrator"],
  "unauthenticated": "anonymous",
  "ignore": ["POST /api/webhooks"]
}
Key What it does Without it
specimens One real object id per templated route, one entry per {placeholder} The operation is not probed at all and every cell reads unmeasured
marker_owner Whose planted marker responses are scanned for No cell is graded marker_scan: present, so marker_reach cannot fire
peer_groups Identities that should reach the same things peer_divergence and sibling_asymmetry stay silent
privilege_order Least privileged first inverted_guard stays silent
unauthenticated The identity carrying no credentials unauthenticated_reach stays silent
ignore Operations never to probe, as METHOD /path/template Everything the specification declares is in scope
base_path Spec path prefix, when the spec declares more than one server map refuses and names the candidates
peer_divergence_unanimous Boolean; whether peer divergence requires unanimity Defaults to true

Veridor never infers these from behaviour. A wrong peer_groups does not produce fewer candidates; it produces confidently wrong ones.

A specimen entry declares exactly one of values (the same id for every identity) or per_identity (a different id per identity). An unknown key is refused rather than ignored, because a typo'd specimens key is an invisible gap in the grid:

matrix.specimens['/a/{id}'][0] has unsupported keys: shared (supported: label,
per_identity, values)

The matrix section is validated by map, not by load_config. A malformed matrix will not stop veridor run.

Proxies

Requests go straight to base_url unless http_proxy or https_proxy, in either case, is set for its scheme and no_proxy does not name its host. Then they go through that proxy, and Veridor says so on stderr before the first request, naming the proxy's host and port (never its user:password) and the variable. run, map, mutate and verify (in its banner) all print it. Measured for this page on an earlier tree (before 38025ed), with a counting listener as the proxy and http://staging.invalid:8080 as the target:

veridor: requests to staging.invalid:8080 go through the proxy 127.0.0.1:18773
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.

The proxy received all 9 requests. With no_proxy=staging.invalid it received 0, and with the demo target on loopback it received 0 and no line was printed.

  • What a proxy can read. For an http:// target, every request, credentials included. For https://, a proxy that only tunnels sees the host and port, but one that inspects TLS with a certificate authority the machine trusts, as many corporate egress proxies do, reads every request in full too. Veridor cannot tell the two apart, and the notice says both. It used to promise the tunnel; the CHANGELOG records an inspecting proxy logging both identities' bearer tokens under that notice.
  • Only the environment counts. The operating system's proxy settings (macOS, Windows) are never read. urllib's default handling read them, and applied them to loopback too, silently.
  • Loopback is always direct: 127.0.0.0/8, ::1, localhost, whatever the environment says. So a capsule verify runs without --base-url never goes through a proxy.
  • Refused with exit 2, before anything is sent, without printing the value: an https:// proxy for an https:// target, because urllib opens the tunnel to a proxy in plaintext, CONNECT line and user:password included (measured for this page: exit 2, 0 requests at the proxy); and a proxy value that is not host:port once its user:password@ is set aside, such as http:/proxy, a %40 for the @, or a path after the port, which urllib reads differently from what was meant. An https:// proxy for an http:// target is used, over TLS.
  • urllib's rules, not curl's. With REQUEST_METHOD set (as under CGI) an upper-case HTTP_PROXY is ignored for an http:// target, all_proxy is never read, and no_proxy matches host names, domain suffixes and *, not CIDR ranges or *.example.com. The notice's hint names the no_proxy variable urllib actually reads, and the bracketed form of an IPv6 host. Where that differs from another tool, the notice, or its absence, is what says which way the requests went.
  • A 407 Proxy Authentication Required is the proxy's answer, not the target's: the request most likely never reached it. run and sequence steps grade such a probe inconclusive (not a finding; a run that drew nothing else exits 3), verify returns INCONCLUSIVE, and map and drift record the cell as unmeasured (proxy_auth_required).

In CI, whoever may add a proxy variable when a build starts chooses where every credential goes, whatever base_url says. The GitLab component removes the proxy variables unless proxy_from_environment is true; the other templates do not (other CI).

${ENV_VAR} substitution

Any string anywhere in the file may contain ${NAME}, matching [A-Za-z_][A-Za-z0-9_]*. It is read from the process environment at load time.

"owner": {"headers": {"Authorization": "Bearer ${OWNER_TOKEN}"}}
export OWNER_TOKEN=...

A missing variable fails the run before any request is sent:

veridor: environment variable 'OWNER_TOKEN' is not set

So does one that is set but empty, or only whitespace:

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 are exit 2. The empty case used to be sent as an empty credential, and the denial it drew was reported as an unexpected denial, blamed on the target. Measured: with VERIDOR_OWNER_TOKEN="" the demo configuration exited 2 and a recording listener received 0 requests; with the variable set, the same configuration sent 9.

Expanded values are written back as references. Every value Veridor expands from a ${NAME}, wherever it sits (a path segment, a ;param, the base URL, a body), is written and printed as ${NAME} in every document, report and console line. A value shorter than 8 characters is the exception, because it cannot be told apart from ordinary text; the command names it on stderr instead:

veridor: ${VERIDOR_SHORT} expanded to fewer than 8 characters, so Veridor did
not replace the value with its reference in what it wrote or printed: a value
that short cannot be told apart from ordinary text. ...

A credential typed literally into the file, or into a flag such as --base-url, is your own text and is written as typed. The rule and its limits are on safety posture.

Name credentials VERIDOR_*. Nothing in run requires it, but it is the convention every shipped CI template uses, and veridor verify expands only VERIDOR_* names in a capsule (see safety); veridor capsule renames anything else on the way out.

veridor coverage is the one exception. It reads the declaration and issues no request — its own help groups its flags under "(offline, static)" — so it substitutes a per-variable placeholder, <absent:NAME>, for an unset or empty variable rather than demanding live tokens for a command that sends nothing. The placeholder carries the variable name, so two absent credentials stay distinguishable and the identical-credentials guard is not tripped by a config whose only fault is unexported secrets.

${capture.<name>} is a different namespace and is deliberately dotted: the environment pattern's character class cannot match a dot, so the two can never collide. A ${capture.…} outside a session is a load error rather than literal text on the wire:

check 'c1' request references undefined capture 'csrf'; only an identity
'session' declares captures, and they reach a request through 'session.inject'
or a login step

A literal credential is refused by veridor capsule

A capsule — a portable, runnable proof of one finding, written by veridor capsule — is built from your configuration as written, before substitution, and is meant to be attached to a disclosure and read by someone else. Writing one is refused if any identity's Authorization, Cookie, X-API-Key, X-Auth-Token, API-Key or Token header is a literal rather than an ${ENV_VAR} reference:

veridor: identity 'invoice_owner' carries a literal Authorization value, and a
capsule is meant to be attached to a disclosure and read by someone else. Move
it to an environment variable first, for example "Authorization": "Bearer
${VERIDOR_INVOICE_OWNER_TOKEN}", and re-run. Nothing has been written.

After that, the whole finished capsule is scanned: credential-looking headers at any depth (each cookie separately), session inject values, every string or number anywhere beneath a credential-named key, credential-named parameters in query strings, ; path parameters, form bodies and JSON text inside strings, and user:pass@ in any URL. A key is credential-named when it ends in password, secret, token, credential(s) or an api, private, secret or access key, with or without a separator (accessToken, refreshToken, aws_secret_access_key), plus jwt, confirmation forms such as password1 and password_confirmation, some non-English words for password, and pass and pwd as words of their own. As a parameter only, key, auth, code, sig, signature, sessionid, session_id, jsessionid and sid count too, so a non-secret there (?code=US) must be a reference as well. A hit is refused the same way, naming paths and never values:

veridor: this capsule would publish 1 literal credential value:
check.request.path (parameter 'api_key'). ... Move each one into an environment
variable and reference it, for example "${VERIDOR_API_KEY}", and re-run.
Nothing has been written.

There is no override flag. A published bearer token cannot be recalled, the reporter would not find out until far too late, and the fix is one line. The capsule instead names the environment variables the reader must supply from their own instance, renaming any that are not VERIDOR_*.

Recognition is by name and position. A secret under a name the rule does not know (a pin, otp or passcode field) is not caught, and a non-secret under a name it does know (pageToken holding a cursor) is refused. Read the capsule before you send it.

This gate is capsule-only. run, map and mutate accept a literal credential, because they do not publish the file.

Flags that decide how a configuration runs in CI

The configuration says what to send and what to expect. These veridor run flags decide what the process does with the answer. The full list is in veridor run --help.

Flag Default What it does
--fail-on POLICY new+confirmed Which findings fail the gate: new+confirmed, new, any, confirmed, none. See run.
--require-complete off (on in the GitHub Action, the GitLab component and the Kubernetes packagings) Exit 3 when any probe went unanswered.
--require-clean-teardown off Exit 3 when a sequence teardown was unclean.
--read-only off Refuse, before any request, a configuration that can send anything but GET, HEAD or OPTIONS.
--base-url URL the configuration's target.base_url Override the target, validated the same way.
--output-dir DIR .veridor Run history.
--json PATH, --sarif PATH none The run document and SARIF.
--report FORMAT=PATH none Also write the run as sarif, junit, gitlab-sast, codequality or markdown; repeatable; PATH may not be -. See reports.
--wait-for-target SECONDS 0 (no wait) run and map: before the first request, try a plain TCP connection to the target's host and port once a second until one is accepted or SECONDS pass, then run either way. A whole number; anything else is refused with exit 2. It never changes a verdict. verify <capsule> refuses it.

The output files (--json, --sarif, --coverage, --badge and each --report destination) are checked before the first request: they must be distinct and may never overwrite the configuration, the baseline, the specification or the managed run history. No input may be a file the command itself rewrites under --output-dir: latest.json for run and verify, the mutation/ report for mutate. --baseline .veridor/latest.json used to be replaced by the run it was compared against, and the re-run then compared a live finding against itself and exited 0; copy latest.json to a file of its own and pass that. A run under runs/ is accepted, because nothing rewrites it. Paths are compared as files, not strings, so on a case-insensitive filesystem VERIDOR.JSON is refused where veridor.json would be. Measured for this page on an earlier tree, before 38025ed (macOS, case-insensitive volume): with --output-dir out-mh, --baseline out-mh/latest.json and --baseline OUT-MH/LATEST.JSON were both refused with exit 2, ... is the file this command writes its own result to (latest.json under --output-dir). Then run, map, mutate and verify create each output directory and write and remove a test file in it, and in the directory of every output file. If that fails they exit 2 before the first request:

veridor: cannot write the results of `run` to --report junit=ro/j.xml ro/j.xml (resolved: ...): could not create a file in its parent directory ro (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: point --report at a directory this user can write, or, in a container, run it as the owner of the mounted directory (docker run --user "$(id -u):$(id -g)").

Measured on 2026-09-24 against the integration branch (5ee6158), with a listener counting requests: 0 for that refusal and for an unwritable --output-dir under run and map, and 9 and 12 for the same commands with a writable directory. The check covers the directory, not the file: an existing read-only report file in a writable directory is replaced, as it always was.

--wait-for-target exists for a target that is still starting when the gate does: an Argo CD PostSync hook that starts the moment the new pod is Ready, a service container in CI. It runs after the write check and, in run, after the write-mode and liveness disclosures, and before any request, a session login included. One stderr line says how it ended, measured against a closed port and then a listening one:

veridor: target 127.0.0.1:59431 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.
veridor: target 127.0.0.1:59431 accepted a connection after 0s (--wait-for-target 5, 1 attempt(s)).

A target that still refuses connections when the wait runs out is probed anyway, and the probes that go unanswered are graded exactly as they would have been without the wait: the closed-port run above exited 3. The check connects to the target directly even when a proxy carries the requests, and then its line says so.

An accepted connection means a port is open, not that the service 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 and the gate measures that version: the Veridor repository records 0 s under a RollingUpdate Helm upgrade. Kubernetes and Argo CD shows how to aim a gate at the new pods only. run records the wait in the run document as target_wait; map prints the line and records nothing. The default is 0 on the command line, in the GitHub Action and in the GitLab component, and 60 in the Helm chart, the Kustomize base and the Argo Rollouts template.

Names are constrained

Check ids, sequence ids and identity names must match ^[A-Za-z0-9._-]+$.

a check id must be letters, digits, dot, underscore and hyphen only -- it is a
fingerprint input and is written to your terminal, so control characters are
refused: 'a/b'

Two reasons, both concrete. These strings are interpolated into console output, into GitHub workflow commands and into SARIF: a check id carrying ESC[9A and ESC[J rewrote a run summary into a clean-looking pass in a terminal and in any CI log viewer that renders ANSI. The exit code stayed correct, so that never defeated the gate; it defeated the human reading the log. And a check id is a fingerprint input — it decides which baseline entry a finding matches — so the name is validated rather than stripped, because silently rewriting one would silently re-key a baseline.

name and description are free text and are not constrained. They are not fingerprint inputs and not path components, and they are escaped where they are printed.

Annotations

Unrecognised keys are accepted at the top level, inside target, inside an identity, inside a check and inside a sequence. Configurations have always carried annotations, and veridor init writes _comment itself. A request block is the exception: there only a key starting with _ is an annotation, and any other unknown key is refused (request).

"anonymous": {
  "headers": {},
  "_comment": "no credentials on purpose"
}

The exception is the near-miss rule described above: outside target, a check's own keys and a teardown entry's expect, a key that resembles a real one, or that contains marker, is refused with a suggestion. A key starting with _ is never judged, so _marker_note is an annotation.

The shipped demo configuration

fixtures/veridor.demo.json in the tool repository, complete — reflowed for width, and otherwise the same document. It pairs with scripts/demo_server.py and is what the quickstart runs.

{
  "version": 1,
  "target": {
    "base_url": "http://127.0.0.1:8765",
    "timeout_seconds": 3
  },
  "identities": {
    "invoice_owner": {
      "headers": {"Authorization": "Bearer owner-token"},
      "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
    },
    "other_tenant": {
      "headers": {"Authorization": "Bearer outsider-token"},
      "marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
      "marker_probe": {
        "method": "GET",
        "path": "/api/invoices/inv-200"
      }
    },
    "administrator": {
      "headers": {"Authorization": "Bearer admin-token"}
    },
    "anonymous": {
      "headers": {}
    }
  },
  "checks": [
    {
      "id": "invoice-tenant-isolation",
      "name": "Invoice tenant isolation",
      "request": {"method": "GET", "path": "/api/invoices/inv-100"},
      "expect": {
        "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
        "other_tenant": {"status": [404], "excludes_marker": "invoice_owner"},
        "anonymous": {"status": [401]}
      }
    },
    {
      "id": "audit-admin-role",
      "name": "Audit log administrator role",
      "request": {"method": "GET", "path": "/api/admin/audit"},
      "expect": {
        "administrator": {"status": [200]},
        "invoice_owner": {"status": [403]},
        "other_tenant": {"status": [403]}
      }
    }
  ]
}

Four identities, two checks, six probes. The other_tenant marker and its marker_probe exist so that identity's own credential and marker are proven live before any exclusion is graded — the second check names other_tenant but asserts nothing about markers, so without the probe its credential would never be exercised in a direction that could fail.

Against the demo server started with --vulnerable, that file produces one confirmed finding and exit 1. Without the flag, 6/6 probes matched and exit 0. Neither result says the declaration above is correct.

Refusal index

Every entry exits 2, before any request is sent.

Refusal Cause
configuration must be an object with version 1, 2 unsupported version
sequences requires configuration version 2 sequences at version 1
the configuration has unsupported top-level keys: K (did you mean ...) a near-miss of a top-level key
coverage has unsupported keys / coverage.denial_statuses must be a non-empty list of 4xx authorization refusal statuses a malformed coverage block, under coverage (run --openapi and map send their requests first; see coverage)
invalid JSON in <path> malformed file
<path> is nested too deeply to parse JSON nesting past the decoder's recursion limit
target must be an object missing or wrong-typed target
target.base_url must be an http(s) URL with a host non-HTTP scheme, or no host
target.timeout_seconds must be a number greater than zero and no more than 60s out of range, NaN, or infinite
identities must define at least two users fewer than two identities
identity 'X' defines none of headers, marker, marker_probe, session credentials written one level too high
identities 'X' and 'Y' send identical credentials duplicated token
identity 'X' marker must be between 24 and 128 characters weak marker
identities 'X' and 'Y' share the same marker ambiguous ownership
identity 'X' marker appears in the request sent by check 'C' reflection guard
identity 'X' marker_probe names an identity that defines no 'marker' field probe with nothing to prove
checks must be a non-empty list no checks, under run or mutate
duplicate check id: C id reused, including against a sequence id
a check id must be letters, digits, dot, underscore and hyphen only unsafe name
check 'C' must expect results for at least two users fewer than two identities in expect
check 'C' references unknown identities: X expect names an undeclared identity
check 'C' expectation for 'X' requires HTTP status values missing or out-of-range status
check 'C' expectation for 'X' has unsupported keys: contain_marker near-miss key
check 'C' expectation for 'X' cannot set both contains_marker and excludes_marker two assertions on one probe
check 'C' expectation for 'X' excludes_marker 'Y' requires a control in the same check unfalsifiable exclusion
check 'C' request.method must be upper case lower-case verb
check 'C' request.path must be a same-origin absolute path scheme, host, fragment, non-ASCII, or no leading /; or a path that becomes an absolute URL when joined
check 'C' request.path must not contain a '.' or '..' segment a dot segment in any spelling, ..;, or beside an encoded / or \
check 'C' request.headers sets a Host header a Host header, in any case, in any header map
check 'C' request has unsupported keys: K a request-block key other than method, path, headers, json, body or a _ annotation
check 'C' request cannot define both json and body two bodies
N check(s) still contain an unsubstituted path placeholder {id} left in a path, under a verb that sends it
check 'C' request references undefined capture 'X' ${capture.…} outside a session
--read-only refuses DELETE in check 'C' request mutating declaration under --read-only
environment variable 'X' is not set a ${X} with no value in the environment
environment variable 'X' is set but empty a ${X} that is empty or only whitespace
http_proxy names an https:// proxy for an https:// target (any proxy variable) the tunnel would be opened in plaintext
http_proxy is set, and urllib cannot read a proxy host from it (any proxy variable) a proxy value that is not host:port after its user:password@
<path> is the file this command writes its own result to an input that is latest.json under --output-dir (or the mutation/ report, for mutate), in any spelling that names the same file
this capsule would publish N literal credential value(s) veridor capsule found a literal credential; nothing is written

Session-specific refusals are listed on sessions and CSRF.