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¶
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:
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:
"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:
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¶
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:
Writing credentials one level too high is refused, and this is the refusal worth knowing about:
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.
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
Cookieheader and asession.add_cookie_headerdoes nothing when aCookieheader is already present, so the session cookie would vanish with no diagnostic. veridor maprefuses a session-backed identity outright.mapbuilds every probe from staticheadersand does not log in, so it would probe that identity anonymously and record the anonymous reach under its name. Userun, 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:
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:
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. Forhttps://, 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 capsuleverifyruns without--base-urlnever goes through a proxy. - Refused with exit
2, before anything is sent, without printing the value: anhttps://proxy for anhttps://target, because urllib opens the tunnel to a proxy in plaintext, CONNECT line anduser:passwordincluded (measured for this page: exit2, 0 requests at the proxy); and a proxy value that is nothost:portonce itsuser:password@is set aside, such ashttp:/proxy, a%40for the@, or a path after the port, which urllib reads differently from what was meant. Anhttps://proxy for anhttp://target is used, over TLS. - urllib's rules, not curl's. With
REQUEST_METHODset (as under CGI) an upper-caseHTTP_PROXYis ignored for anhttp://target,all_proxyis never read, andno_proxymatches host names, domain suffixes and*, not CIDR ranges or*.example.com. The notice's hint names theno_proxyvariable 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 Requiredis the proxy's answer, not the target's: the request most likely never reached it.runand sequence steps grade such a probe inconclusive (not a finding; a run that drew nothing else exits3),verifyreturnsINCONCLUSIVE, andmapand 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.
A missing variable fails the run before any request is sent:
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).
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.