How Veridor works¶
This page is the mental model to hold before you read the reference. Each term below is the word Veridor itself uses, in its configuration keys, its console output and its JSON documents. Each one links to the page that covers it in full.
One sentence carries the whole model: you declare what each identity should get, Veridor sends the request as every identity, and the build fails when the target disagrees with what you declared. Veridor never decides what the right answer is. Your configuration is the policy; see writing the policy.
you write Veridor does
┌──────────────────────────┐
│ identities │ one probe per identity in `expect`
│ checks: request + expect ├──────────────► target ──► responses
│ markers │ │
└──────────────────────────┘ ▼
verdict + evidence grade per probe
│
baseline run ──► regression diff ◄───────┘
│
--fail-on policy ──► exit code
map and mutate sit on either side of that loop. map looks at the API
before you have declared much and proposes what to declare. mutate looks at
your declarations afterwards and asks whether they could ever fail.
What you declare¶
Identity¶
A named caller, declared under identities. It carries the credentials sent on
its behalf (headers, or a cookie session), and optionally a marker and a
marker_probe. A configuration needs at least two, because one caller cannot
express a boundary; an identity meant to send nothing declares
"headers": {}. See identities and
sessions and CSRF.
Check¶
One request, sent once under every identity its expect names. A check is a
fan-out, not a script: it has an id, a request (method, path,
headers, json, body) and an expect block with at least two entries.
See checks and
policy patterns.
Expectation¶
One entry in a check's expect: what one identity should get from that
request. It holds a status list and at most one of contains_marker or
excludes_marker. An expectation that accepts both an access status and a
denial status, such as [200, 403, 404], matches trivially: it matches whether
the target grants access or denies it, and is reported as non-asserting. See expect.
Marker¶
A high-entropy string you plant in one identity's own data and declare on that
identity, for example vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1. veridor new-marker
prints one that passes validation. contains_marker and excludes_marker take
the name of an identity, not the string: contains_marker says a response must
carry that identity's marker; excludes_marker says it must not, and needs a
control behind it. A marker
is how Veridor tells a changed status code apart from data that actually
crossed. See markers.
Sequence¶
The ordered form of a check, for bugs that need state between requests: a list
of steps, each with a required role (setup or assert), each sent as
exactly one identity (as), each with its own expect, optionally capturing a value for a later step, with a teardown that
always runs. It requires "version": 2. See sequences
and sequences.
What run does with it¶
Probe¶
One request sent as one identity. A check with three expectations is three
probes. A run also sends requests of its own: each marker_probe, and the
liveness controls, which carry a credential Veridor fabricated
(veridor-sham-…) so that a denial any caller would get cannot pass as a
boundary that refused this one. See
the liveness controls.
Verdict¶
How one probe's response compared with its expectation. A probe that matched produces no finding; one that did not produces a finding with one of three verdicts.
| What happened | Counted in the summary line as | verdict on the finding |
|---|---|---|
| The response matched the expectation | matched |
no finding; counted as safe in the run document's summary |
| A reachable target served something the expectation did not allow: a status outside the declared set, or another identity's marker | vulnerable |
vulnerable |
| The target did not serve the request, with a status outside the declared set | unexpected denial |
unexpected_denial |
Nothing was measured, or the result could not be verified: a transport error, a timeout, a 429, a 5xx, a 407; or the declared status arrived but the marker oracle could not be settled (a contains_marker not observed, an excludes_marker with no control, a marker not unique to its owner, a cached response) |
inconclusive |
inconclusive |
Each finding line starts with its verdict as written in the run document,
followed by its grade unless the grade is ungraded or n/a:
[vulnerable (confirmed)], [inconclusive].
A match is agreement with your declaration for one request, never evidence that the boundary is enforced. See verdicts.
Evidence grade¶
How strong the evidence behind a finding is, independent of its verdict:
confirmed (another identity's marker was in the response), suspected (the
status differed and no owner-specific marker was observed, or the marker
crossed but a fabricated credential was served it too, so it cannot be shown
unique to its owner), ungraded (the
status differed and the check has no marker) or n/a (inconclusive).
confirmed is proof the string crossed and of data flow insofar as the marker
is unique to its owner. It is never proof of policy. See
evidence grades.
Finding¶
One probe that did not match its expectation, recorded with a verdict, an
evidence_grade, a rule_id such as VERIDOR-AUTHZ-001, and a stable id.
A finding means observed behaviour differed from your declaration. It does not
establish that the declaration is correct, and the console prints that on
every run as its Scope: line. See
what a finding means.
Against the demo target with its cross-tenant bug switched on:
…
Veridor run 20261110T043403Z-1ea9da: 5/6 probes matched their declaration, 1 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18806
Regression diff: 1 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
…
- [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: …
Baseline and regression diff¶
A baseline is an earlier run document, passed with --baseline. Findings are
matched across runs by their id, a fingerprint over the check id, identity,
finding kind, method, path, request-body digest and expected statuses. A
current finding is new or unchanged; a baseline finding no longer present
is resolved. escalated is not a fourth state: it lists the unchanged
findings now graded confirmed. See the baseline diff.
The run above used the green run of the same configuration as its baseline,
which is why the leak is 1 new.
The gate and exit codes¶
--fail-on picks which findings fail the build: new+confirmed (the
default), new, any, confirmed or none. inconclusive findings never
trip it. The decision is recorded in the run document's gate object, with a
reason code such as findings or nothing_answered.
run exit |
Means |
|---|---|
0 |
Nothing the gate fails on was found |
1 |
The gate failed |
2 |
Configuration, credential, specification, path-safety or undefined-coverage error, including an output path this process cannot write |
3 |
The gate passed but something else did not: not one probe was answered, a session identity failed to log in, or --require-complete or |
--require-clean-teardown found a gap. A finding always wins, so 3 never replaces 1 |
70 is an internal error, 130 an interrupt and 143 a termination. The run
document's status (passed, failed, inconclusive) is computed from the
findings alone and is not the exit code. A run against a port where nothing is
listening:
…
veridor: not one of 6 probe(s) was answered, so this run established nothing. It is not a pass. Check the target URL, the credentials, and that the target is up.
Veridor run 20261110T043447Z-0ea07b: 0/6 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 6 inconclusive
…
That run exits 3. See exit codes and
the gate object.
What map measures¶
map needs two identities and an OpenAPI JSON file, and no checks. It is
read-only by default, and nothing it discovers changes its exit code. See map.
Operation¶
One method and path template from the OpenAPI specification, written the way
the output writes it: GET /api/invoices/{id}. map probes operations;
boundary coverage counts them.
Specimen¶
One concrete set of path-parameter values for one templated operation,
declared under matrix.specimens with a label and values, such as
{"label": "owner_invoice", "values": {"id": "inv-100"}}. Veridor never
guesses identifiers, so a templated operation with no specimen is not probed
and is recorded as skipped_no_specimen. See
matrix.
Access matrix¶
One cell per operation, specimen and identity. A cell's reach is reached,
refused, indeterminate or unknown, and an unknown cell carries an
unknown_reason such as skipped_mutating, rate_limited or server_error.
unknown is never rendered as no access. This is a different vocabulary from
run's verdicts on purpose. See the access matrix
and why the vocabulary differs.
Blast radius¶
For each identity, the operations it was observed to reach. It is a lower bound on observed reach, not a measure of exposure, and it counts operations, never objects: an operation counted as reached may expose one record or every record.
The output below and the candidate under the next heading come from the demo
target with its cross-tenant bug switched on. Against the fixed target,
other_tenant reaches 0 operations and map raises 0 candidates.
Observed reach (lower bound), identity "other_tenant": at least 1 of 5 probed operations returned a success status.
reached 1 refused 2 unresolved 0 unmeasured 2 of 5
no share is rendered: 2 operation(s) were not measured, and a percentage over an incomplete denominator reads as the answer.
reached GET /api/invoices/{id} [specimen owner_invoice] [200]
…
See blast radius per identity.
Candidate¶
An inconsistency between observations in the matrix, raised by one of six
rules: marker_reach, unauthenticated_reach, inverted_guard,
peer_divergence, sibling_asymmetry, method_asymmetry. A candidate is a
hypothesis to declare and test, never a finding: it produces no SARIF result
and never changes an exit code. --promote writes each one as an incomplete
check stub for you to finish.
[marker_reach] candidate -- other_tenant on GET /api/invoices/{id} [specimen owner_invoice]
other_tenant reached GET /api/invoices/{id} and the response carried
invoice_owner's planted marker. Data belonging to another named identity
was observed in other_tenant's response. This is an inconsistency in the
observations, not a finding. Declare the expectation to test it.
…
See anomaly candidates.
Drift¶
map --baseline compares this run's matrix with one recorded earlier and
reports what moved, such as reach_opened (refused, now served) and
marker_appeared. A change is an observation, not a verdict, so drift fails
nothing unless you pass --fail-on-drift opened or any. See
drift.
Access-matrix drift — 2 change(s), 2 that mean more access
…
MORE ACCESS THAN THE BASELINE
[reach_opened] GET /api/invoices/{id} other_tenant [specimen owner_invoice]
refused -> reached
With --fail-on-drift opened, that run exits 1.
What your declarations are worth¶
Coverage¶
Boundary coverage is the share of the specification's operations for which
some check asserts a cross-identity denial (403 or 404 by default; 401 is
authentication and never counts). It is a fact about your configuration, not
about any run. Coverage says a denial is asserted, never that it is enforced.
veridor coverage computes it without sending a request; run --openapi and
map report it too.
Authorization boundary coverage: 2/5 operations (40.0%)
Coverage measures whether a cross-identity denial is ASSERTED for each
operation. It does not measure whether authorization is ENFORCED.
…
3 operations have no cross-identity denial asserted:
GET /api/invoices
DELETE /api/invoices/{id}
POST /api/admin/audit/export
See boundary coverage and coverage is asserted, never enforced.
Mutation score¶
mutate rewrites your expectations one at a time with six operators (M1
invert-denial to M6 sham-credential), re-grades each mutant against the
target, and reports it killed, survived, aberrant, errored,
not_applicable or skipped. The score is killed over generated. A survivor
is a check that may pass whatever the target does. The score is about your
declaration, never your target, and mutate has its own exit code: 0 all
killed, 1 a survivor, 2 nothing measured, a mutant errored, or a mutant was aberrant (the
verdict changed in the wrong direction).
Mutation score: 0.92 (12 killed / 13 generated)
survived 1 aberrant 0 errored 0 not applicable 2 skipped 3
…
[SURVIVED] M1 invert-denial -- check 'widget-read-permissive', identity outsider
Coverage and the mutation score multiply rather than add: full coverage made of
checks that cannot fail is worth nothing. See mutate.
Where to go next¶
- Your first finding runs the loop at the top of this page against the demo target.
- Writing the policy turns a boundary you have in mind into checks.
- The command-line reference lists every flag.
- Troubleshooting starts from the message you are looking at.
The outputs on this page come from fixtures/veridor.demo.json,
fixtures/veridor.map-demo.json, fixtures/openapi.demo.json and
fixtures/veridor.mutation-demo.json against scripts/demo_server.py and the
mutation fixture, run on a local port, and are abridged where marked.