Skip to content

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 exit 0 with "0 probes matched" and read as verified. Answered means the authorization layer answered — the same predicate the inconclusive verdict uses, so the exit code and the grade cannot disagree about what a measurement is.
  • --require-complete exits 3 when 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:

veridor report --from-run current.json --format sarif --output veridor.sarif

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