run — does the boundary hold, and did it change?¶
run is the gate, and the only verb that can fail your build. It issues every
declared expectation as every declared identity, grades the evidence, diffs
against a baseline, and writes SARIF.
veridor run --config veridor.json --baseline baseline.json --json current.json --sarif veridor.sarif
verify is an alias for run, and additionally takes a capsule file, which it
treats as untrusted input (safety posture).
Neither needs an OpenAPI specification — --openapi only adds boundary
coverage to the report.
What you declare¶
Each check sends one request under at least two named identities.
{
"version": 1,
"target": {"base_url": "https://staging.example.com", "timeout_seconds": 10},
"identities": {
"owner": {
"headers": {"Authorization": "Bearer ${OWNER_TOKEN}"},
"marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
},
"other_tenant": {"headers": {"Authorization": "Bearer ${OTHER_TENANT_TOKEN}"}},
"anonymous": {"headers": {}}
},
"checks": [
{
"id": "invoice-tenant-isolation",
"request": {"method": "GET", "path": "/api/invoices/inv-100"},
"expect": {
"owner": {"status": [200], "contains_marker": "owner"},
"other_tenant": {"status": [403, 404], "excludes_marker": "owner"},
"anonymous": {"status": [401]}
}
}
]
}
${NAME} is read from the environment at load time; a missing or empty
variable fails the run before any request is sent. Requests go out as written,
except that redirects are never followed — a 3xx is an observed response, and
credentials are never forwarded to a new host — Accept-Encoding is forced to
identity, and Cache-Control and Pragma to no-cache. A run also sends
requests of its own: each marker_probe, and the
liveness controls.
A request takes method, path, headers, json and body; any other key
is refused with exit 2 unless it starts with _. A path that would leave the
target is refused when the file is loaded: one that becomes an absolute URL
when joined onto base_url, and one with a . or .. segment in any
spelling a server on the way might decode (..%2f, ..;, %c0%ae). So is a
Host header in any header map, because urllib would send it in place of the
URL's host. And every request is checked, just before it is sent, against the
base URL's origin and path. Requests go through a proxy only when
http_proxy or https_proxy is set for a target that is not loopback, and
Veridor says so on stderr first. The detail is in the
configuration reference and on
proxies. The full key list is in the
configuration reference; multi-step
sequences and cookie
sessions are declared in the same file and run as part
of this command.
What one run says¶
Veridor run 20260805T023915Z-69b0e4: 5/6 probes matched their declaration, 1 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:8765
Regression diff: 1 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
Scope: Veridor evaluates the expectations you declared. A finding means observed behaviour differed from your declaration; it does not establish that the declaration is correct.
Pass: A matched probe means the target agreed with your declaration for that one request. It is not evidence that the boundary is enforced, and an expectation that accepts both access and denial matches trivially.
- [vulnerable (confirmed)] invoice-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]) and the response carried 'invoice_owner''s marker
Exit code 1. Every run prints its own Target: line, so you can always see
what the verdict above it was a verdict about.
Verdicts¶
| Verdict | Means |
|---|---|
safe |
the response matched the declared expectation. Printed as matched, never "safe": it is agreement with your configuration for one request |
vulnerable |
a reachable target served a response the declaration did not allow — a status outside the declared set, or a response carrying another identity's marker |
unexpected_denial |
the target did not serve the request: a status outside the declared set and outside 2xx (401, 403, 404, and also 400, 405, 409, a 302 and the like), other than the statuses graded inconclusive, and no marker was observed. The declaration still failed and the gate still fires, but nothing was returned to the identity |
inconclusive |
execution or the marker oracle could not produce a verdict |
A 429, a 5xx, a proxy's 407 (Proxy Authentication Required: the
request most likely never reached the target), a transport error and a body
too large to scan are not answers from the authorization layer. They are
graded inconclusive, and --fail-on does not fire on them: a gate that fires
when nothing was measured is not strict, it is wrong, and it is the reason gates
get switched off. --require-complete fails a run on them (exit 3), and a
run in which not one probe was answered exits 3 without it.
Full detail is in verdicts and evidence grades.
Evidence grades¶
A status code alone cannot tell you whether any data moved. 200 where you
declared 404 is a disagreement with your declaration; it is not yet evidence
that the other tenant saw anything. A marker — a high-entropy string planted
in one identity's own data — closes that gap, and only that gap.
| Grade | Meaning |
|---|---|
confirmed |
the response carried another identity's declared marker. Proof the string crossed, and proof of data flow insofar as the marker is unique to that identity's data — a premise you supply by planting it. Never proof of policy |
suspected |
the observed status differed from the declared one, but the marker was not observed, so exposure is unproven |
ungraded |
the observed status differed and no marker was configured for this check |
n/a |
inconclusive: the request failed, or the oracle could not be verified |
excludes_marker requires a control, and Veridor refuses to load a
configuration without one: absence is only meaningful if presence was
demonstrable. If a credential Veridor fabricated gets the same marker back, the
string is not owner-specific and the grade caps at suspected.
Grades and verdicts are finding properties, outside the fingerprint, so adding markers to an existing suite never invalidates your baseline.
The baseline diff¶
Findings are matched across runs by a stable fingerprint over the check id, identity, finding kind, method, path, request-body digest, and expected statuses.
| Class | Means |
|---|---|
new |
present now, absent from the baseline |
resolved |
present in the baseline, absent now |
unchanged |
present in both |
escalated |
unchanged, but the grade rose to confirmed since the baseline |
--baseline takes a prior run JSON. Write one with --json current.json and
commit it, the way you would commit a test snapshot.
Every run against a baseline names what that baseline is absorbing:
Regression diff: 0 new, 0 resolved, 1 unchanged
Suppressed by the baseline: 1 finding(s) about your target are present in this
run AND in the baseline, so `--fail-on new` does not see them.
1 of those carries a marker another identity's response actually contained. The
default policy gates on those regardless of age; `--fail-on new` alone would not.
invoice-tenant-isolation / other_tenant [0ec4c5cbd79deaed6e9f]
--fail-on selects the exit-code policy¶
| Policy | Exits 1 when |
|---|---|
new+confirmed (default) |
either of the two below fires |
new |
a finding is absent from the baseline |
any |
any finding exists at all |
confirmed |
any finding is graded confirmed, whether it is new, unchanged, or newly escalated |
none |
never. Report-only — run exits 0 under this policy even when --openapi is passed and boundary coverage cannot be measured |
Why the default is the union¶
new is a set difference over finding ids against baseline.json — a file this
documentation asks you to commit to the repository. Finding ids are
deterministic and stable across runs, which is what makes the diff useful and is
also the whole problem: a pull request that adds twenty characters to that file
moves a live confirmed cross-tenant leak from new to unchanged, and the
build goes green.
That is measurable on the demo target. The same run, the same live leak, two policies:
| Command | Console says | Exit |
|---|---|---|
veridor run --config fixtures/veridor.demo.json --baseline current.json --fail-on new |
1 vulnerable |
0 |
veridor run --config fixtures/veridor.demo.json --baseline current.json --fail-on new+confirmed |
1 vulnerable |
1 |
A gate whose verdict depends on a file inside the diff it is gating is not a gate
There is no baseline validation that fixes this, and adding one would be security theatre. A tampered baseline and a legitimately triaged one are the same bytes: "we accepted this finding last quarter" and "I accepted it in this diff" are indistinguishable by inspection, because they are the same operation. Veridor records the baseline's content digest in every run document so two runs claiming to compare against the same baseline can be shown to have read different files — but that is forensics after the fact, not a gate.
So the gate moved instead. new+confirmed fires on a finding that is new, or
a finding whose response actually carried another identity's marker, regardless
of how long it has been in the baseline. Proof of data flow does not become
acceptable by being old.
confirmed alone is the narrowest gate. It is a complement to new, not a
replacement: a genuine authorization break that happens not to expose a marker
is graded suspected and will not trip it, and an over-strict declaration will
trip it on behaviour that is entirely legitimate. Use new as the pull-request
gate and confirmed where only measured data flow may block a release.
--fail-on new is still available and still means what it says. It is not the
default, and it is not what this documentation recommends for CI.
The liveness controls¶
An expectation that accepts only denial statuses (401, 403, 404, 405,
407) is satisfied by a boundary that refused this identity and by a
credential that expired or was mistyped. Those are different facts and they
produce the same green line.
To tell them apart, run issues one probe per such GET, HEAD or
OPTIONS request — not per identity — carrying a credential it fabricated.
The value is a veridor-sham-… string that authenticates to nothing. No
fabricated credential is sent with POST, PUT, PATCH or DELETE or inside
a sequence; those requests are named on the console and listed in
liveness.requests_not_probed. The count is printed before the first one is
sent:
veridor: liveness controls -- 2 extra request(s) carrying a credential Veridor fabricated,
one per GET, HEAD or OPTIONS request whose declaration accepts only denial statuses.
Without them an expired token reads as a boundary that held. A denial the fabricated
credential's answer does not tell apart counts as unproven when the identity presents
a credential, some identity was served in the same check, and nothing else in this run
proves the identity live (a 2xx it drew on a request where an identity with no
credential, or the fabricated credential, was refused; or its marker_probe). The value
authenticates to nothing; pass --no-prove-liveness to skip them.
If the target answers your identity exactly as it answers a caller it cannot
possibly know, that probe established nothing. The run reports it on the console
and under liveness.unproven in the run JSON.
This is stated, never graded. An identity asserted only to be denied and
never served anywhere is the ordinary shape of an authorization suite, so a
finding would fire on most correct configurations. The remedy is one line: give
that identity a marker_probe naming something it should be served, and the
line disappears because the credential is now provably live.
--no-prove-liveness skips the extra requests. On the shipped fixtures the
controls cost +33%, because they are one per request rather than one per
identity.
--read-only¶
A hard refusal, before a single request is sent: the load fails with exit 2 if
any request the configuration can issue — check, sequence step, teardown,
login, or marker probe — uses a method outside GET, HEAD, and OPTIONS.
This is the recommended default for any target you did not personally
provision. veridor init scaffolds DELETE and POST checks by default, and a
sequence that provisions a fixture writes to the target by construction — see
write safety.
--require-complete¶
Coverage cannot fail silently, and this is the half that is opt-in.
- Not one probe answered → exit
3, no flag required. A wrong base URL or an expired token would otherwise exit0with "0 probes matched" and read as verified. Answered means the authorization layer answered — the same predicate theinconclusiveverdict uses, so the exit code and the grade cannot disagree about what a measurement is. --require-completeexits3when any probe went unanswered. Those boundaries are unverified rather than verified, and a CI job reads an exit code, not a summary line.- It also promotes an unproven denial to exit
3, because that is the same question one layer up: was this boundary measured? And it promotes an expectation that accepts both access and denial, which asserts nothing.
The GitHub Action turns require-complete on by
default, because selecting the Action is itself the request for a gate.
A real finding always wins the exit code: 1 is returned ahead of 3.
SARIF¶
--sarif veridor.sarif writes SARIF 2.1.0 for code scanning. Results carry
partialFingerprints.veridorFindingId/v1 plus verdict, evidenceGrade and
markerState properties, so that code scanning can track the same identity
Veridor does. No Veridor SARIF has been uploaded to GitHub yet, so that
tracking has not been observed. See SARIF and code scanning.
Regenerate SARIF, or JUnit, GitLab SAST, Code Quality or Markdown, from a persisted run without repeating the probes:
--report FORMAT=PATH writes the same reports from the run itself. See
reports.
What is never written down¶
Response bodies are never persisted. Evidence holds status, duration, body size,
a SHA-256 digest, and — when a marker is configured — booleans derived from an
in-memory scan. No response text and no headers reach disk, including on the
failure path. Query values are redacted from stored URLs. Every value expanded
from a ${NAME}, in a path or anywhere else, is written and printed as
${NAME}, except a value under 8 characters, which the command names on stderr
instead; a token typed literally into a path is written as typed
(safety posture). The marker literal
itself never reaches any artifact, and a configuration that would send a marker
in the request it later matches is rejected outright.
A body that was not fully materialized never produces a claim of absence. A
stream cut short of its declared Content-Length, a compressed stream that
ended mid-member, and a body past the scan ceiling all report unknown with a
reason rather than absent.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
nothing the gate fails on was found, and coverage (if requested) was measured. A finding the policy does not gate on, and an unanswered probe without --require-complete, exit 0 too (what exit 0 means) |
1 |
the gate failed |
2 |
configuration, credential, specification, path-safety, or undefined-coverage error — including a --read-only refusal, an unset or empty ${VAR}, and an --output-dir or report path this process cannot write, each refused before any request |
3 |
the security gate passed but something else did not: an unclean sequence teardown under --require-clean-teardown, a probe unanswered or a denial unproven under --require-complete, or not one probe answered at all, which needs no flag. A real finding always wins, so 3 never replaces 1 |
70 |
internal error: Veridor crashed. Never a finding |
130 |
interrupted by SIGINT. Teardown for the in-flight sequence ran first |
143 |
terminated by SIGTERM (a cancelled job, a pod termination). Teardown for the in-flight sequence ran first |
The run document carries its own status, and it is not the exit code — the
exit code depends on --fail-on and the other flags. The decision the process
exited with is recorded separately, in the run document's
gate object.
status |
Means |
|---|---|
passed |
no finding at all: nothing vulnerable, unexpected_denial or inconclusive |
failed |
at least one finding is vulnerable or unexpected_denial |
inconclusive |
no finding is vulnerable or unexpected_denial, and at least one is inconclusive |
inconclusive turns on the presence of one ungradeable probe, not on the
absence of every measurement. A run with five matched probes and one 429 is
inconclusive: part of what it was asked to measure, it did not.
Exit codes
covers how to read status and the exit code together.
Output paths are validated before anything runs: --json, --sarif,
--coverage, --badge and every --report path must be distinct and may
never overwrite the configuration, the baseline, the specification, or the
managed run history. No input may be latest.json under --output-dir, the
file every run rewrites: --baseline .veridor/latest.json used to be replaced
by the run it was compared against, so the re-run compared a live finding
against itself and exited 0. Copy it to a file of its own and pass that.
Paths are compared as files, so on a case-insensitive filesystem
VERIDOR.JSON is refused where veridor.json would be. Then run creates each output directory and writes and
removes a test file in it; if that fails it exits 2 before the first request,
naming the path, the uid:gid it runs as, and the fix. Without the check, a run
into an unwritable directory probed the target and then lost the result. For a
target that is still starting when the run does, see --wait-for-target in
the configuration reference.
Next¶
- mutate — whether the gate you just passed could ever have failed.
- exit codes — every code, for every verb.
- claims and limits — what a finding does and does not mean.