Skip to content

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

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.