Skip to content

Write your access policy

You have an API and an idea of who may do what with it. This page takes you from that idea to a veridor.json that writes it down, and then shows the same file failing against a broken target and passing against a fixed one.

Every configuration and command below was run against the demo API from the tool repository (scripts/demo_server.py), on port 18801, first with --vulnerable and then without it. The output is real and abridged; … marks a cut.

You need a checkout to follow along

The demo server and fixtures/openapi.demo.json live in the git repository, not in a wheel. The source repository is private until it is published, so for now this page shows what each step prints. See install from a checkout.

Start the demo with the bug switched on:

python3.13 scripts/demo_server.py --port 18801 --vulnerable
Demo API: http://127.0.0.1:18801

Steps 1 to 4 run against it. Step 5 restarts it without --vulnerable.

What Veridor means by a policy

A policy, to Veridor, is two lists in one file:

  • identities: the named callers that matter to your boundary, each with the credentials it sends;
  • checks: one request each, with an expectation per identity for what that identity must get back.

veridor run sends each check's request once as every identity the check names, and compares each response with that identity's expectation. That is the whole model. There is no role engine, no rule language and no inference.

Veridor consumes the policy. It cannot audit it. Every run prints that bound:

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.

A declaration that forbids access somebody is entitled to produces the same finding as a real break. So the file is the thing to review, and the steps below are about making each line of it say exactly what you mean. Your configuration is the policy has the long form.

The policy this page writes down for the demo API:

Who Invoice inv-100 (tenant acme) Export acme.json Audit log
owner (tenant acme) read read refused
other_tenant (another tenant) not found not found refused
admin (administrator role) not found — read
anonymous (no credential) authentication required authentication required authentication required

Write your own table like this first, in words. The JSON is a transcription of it.

Step 1: list the identities

An identity is a named caller. You need at least two, because one caller cannot express a boundary between callers. In practice you want one identity per side of every boundary you care about:

  • each role: a regular user, an administrator, a read-only member;
  • each tenant: at least two, so one can be the owner of a record and the other the outsider;
  • anonymous: an identity with no credential at all, declared explicitly as "headers": {};
  • a dead credential: you do not have to declare one. Veridor fabricates one itself, the liveness controls below.

Credentials go in headers, as ${ENV_VAR} references

The file starts with the version and the target, then the identities:

"version": 1,
"target": {"base_url": "http://127.0.0.1:18801", "timeout_seconds": 5},
"identities": {
  "owner": {"headers": {"Authorization": "Bearer ${VERIDOR_OWNER_TOKEN}"}},
  "other_tenant": {"headers": {"Authorization": "Bearer ${VERIDOR_OTHER_TENANT_TOKEN}"}},
  "admin": {"headers": {"Authorization": "Bearer ${VERIDOR_ADMIN_TOKEN}"}},
  "anonymous": {"headers": {}}
}

headers is sent on every request made as that identity. Any header works: Authorization, X-API-Key, a Cookie. Cookie logins with CSRF have their own block; see sessions and CSRF.

${NAME} is read from the environment when the file loads. An unset variable stops the run before any request, with exit 2:

env -u VERIDOR_ADMIN_TOKEN veridor run --config veridor.json
veridor: environment variable 'VERIDOR_ADMIN_TOKEN' is not set

A variable that is set but empty is refused the same way. Name the variables VERIDOR_*: veridor init and the shipped CI templates use that convention, and veridor verify expands only VERIDOR_* names in a capsule.

Never type a secret into the file. run, map and mutate accept a literal credential, because they do not publish the file, but the file is meant to be committed and reviewed. veridor capsule, which writes a proof meant for someone else, refuses one outright. With the owner's token typed in literally:

veridor capsule --config literal.json --check invoice-tenant-isolation --output cap.json
veridor: identity '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_OWNER_TOKEN}", and re-run. Nothing has been written.

Exit 2, and no file. The same command on the ${…} version of the file exits 0. The rules for what is written back to disk are on safety posture.

Two identity mistakes are refused at load time, both with exit 2: writing the credential one level too high ({"owner": {"Authorization": …}}, which would probe anonymously under the name owner), and two identities sending the same credential, which would compare an identity with itself. The messages are in the configuration reference.

Step 2: pick the operations

A check names one concrete request. You can write them from memory, but two commands help you find the operations you would otherwise miss. Both read an OpenAPI 3 or Swagger 2 JSON file; run itself never needs one. The commands below use a copy of fixtures/openapi.demo.json named openapi.json.

Start from map: who reaches what today

Add a matrix section to the identities from step 1 (it is read only by map; run ignores it). map needs one real object id per templated path, and the relationships it cannot observe:

"matrix": {
  "specimens": {
    "/api/invoices/{id}": [{"label": "owner_invoice", "values": {"id": "inv-100"}}]
  },
  "peer_groups": {"tenants": ["owner", "other_tenant"]},
  "privilege_order": ["anonymous", "other_tenant", "admin"],
  "unauthenticated": "anonymous"
}
veridor map --config veridor.json --openapi openapi.json

Against the vulnerable demo, abridged:

Veridor access matrix -- this will issue 12 requests to 127.0.0.1:18801.
  operations: 3 (of 5 in the spec; 2 skipped as non-read-only)
  identities: 4      specimens: 1 per operation (max)
  methods:    GET (read-only)
…
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
…
    reached  GET /api/invoices/{id} [specimen owner_invoice]  [200]
…
Authorization boundary coverage: not measured (no_matches)
…
0 candidates.
…
  No inconsistencies were computed from this matrix. This does not mean the
  observed accesses are correct -- only that they are mutually consistent.
  Consistency is not authorization.

Exit 0. Read the reach lines, not only the candidate count. other_tenant was served the owner's invoice, and with no marker declared yet map raised no candidate about it. Step 4 adds a marker and the same command then raises one. Boundary coverage is not measured because the file has no checks yet.

map is read-only by default and rate limited; the details are on map.

Or scaffold from the specification with init

veridor init --openapi openapi.json --output scaffold.json
Wrote 5 checks from 4 paths to scaffold.json  (--for run: you declare the policy, `run` gates on it)
  target.base_url: http://127.0.0.1:8765  (spec)
  Confirm that URL names a test environment before running.

  This scaffold contains DELETE, POST checks. Running it writes to the target.
  Delete those checks, or pass --read-only to refuse them before anything is sent.
…
Every check is a claim you are making about your API, not a measurement. Edit before you trust.
Expect the first run to be red: this file guesses that 'owner' is allowed everywhere,
including on routes no owner is entitled to. Coverage reports this scaffold as unreviewed until you delete "_generated_by".

The scaffold has one check per operation and two identities, owner and other_tenant, and every check says the same thing: owner gets [200] (or [200, 204], [200, 201] for writes) and other_tenant gets [403, 404]. For the audit log, that is the opposite of the policy above. Treat the scaffold as a list of operations to decide about, not as a policy: delete the checks for operations with no per-identity boundary, rewrite the rest, and replace each {id} with a real id (Veridor refuses to send an unsubstituted placeholder). While the file still carries _generated_by, veridor coverage prints THIS NUMBER IS NOT YOURS YET.

Candidates and --promote

When map does raise a candidate, --promote PATH writes it as a deliberately incomplete check you can paste into checks. Step 4 shows one, and what run prints if you paste it in unedited.

Step 3: write an expectation per identity

Each check sends one request as every identity in its expect, and each entry declares what that identity must receive:

"checks": [
  {
    "id": "invoice-tenant-isolation",
    "name": "An invoice is readable by its own tenant only",
    "request": {"method": "GET", "path": "/api/invoices/inv-100"},
    "expect": {
      "owner": {"status": [200]},
      "other_tenant": {"status": [404]},
      "admin": {"status": [404]},
      "anonymous": {"status": [401]}
    }
  },
  {
    "id": "export-tenant-isolation",
    "name": "A tenant export is readable by that tenant only",
    "request": {"method": "GET", "path": "/api/exports/acme.json"},
    "expect": {
      "owner": {"status": [200]},
      "other_tenant": {"status": [404]},
      "anonymous": {"status": [401]}
    }
  },
  {
    "id": "audit-admin-role",
    "name": "The audit log is readable by administrators only",
    "request": {"method": "GET", "path": "/api/admin/audit"},
    "expect": {
      "admin": {"status": [200]},
      "owner": {"status": [403]},
      "other_tenant": {"status": [403]},
      "anonymous": {"status": [401]}
    }
  }
]

The rules the loader holds you to:

  • id is unique and limited to letters, digits, ., _ and -. It is a fingerprint input, so renaming a check re-keys its findings against a baseline.
  • expect names at least two identities, each declared under identities.
  • status is a list of the statuses you accept, each 100–599. A bare integer is read as a one-item list.
  • request takes method, path, headers, json and body, and nothing else except _ annotations. method is upper case.
  • Anything other than GET, HEAD or OPTIONS writes to the target. Pass --read-only to refuse such a file before anything is sent; see writing to the target.

The full list of keys and refusals is in the configuration reference.

Narrow every status list to what you require

Decide what your API answers an outsider. The demo hides existence, so an outsider gets 404, and the policy says [404]. If yours answers 403, write [403]. [403, 404] is a legitimate declaration when either is acceptable, and a wider one than either alone.

An expectation that accepts both access and denial matches whatever the target does. Widen other_tenant to [200, 403, 404] on the invoice check, and declare admin as [200] there too, and run it against the vulnerable demo:

veridor run --config veridor.json
Veridor run 20261110T044039Z-a880aa: 9/11 probes matched their declaration, 1 vulnerable, 1 unexpected denial, 0 inconclusive
…
Non-asserting: 1 expectation(s) accept both access and denial, so they assert nothing about the boundary:
  - invoice-tenant-isolation / other_tenant: expectation [200, 403, 404] for identity 'other_tenant' on check 'invoice-tenant-isolation' accepts both access and denial, so it asserts nothing about the boundary; it matches whatever the target does
- [unexpected_denial] invoice-tenant-isolation / admin: Identity 'admin' received HTTP 404 (declared: one of [200]). The target refused this request, so nothing was returned to this identity on this probe.
…
- [vulnerable] export-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]).
…

The vulnerable demo serves other_tenant the owner's invoice, and with the widened list that probe matched. The leak disappeared from the findings; the Non-asserting: line is the only trace of it. The line is a disclosure, not a finding: it does not change the exit code unless you pass --require-complete, which turns it into exit 3 when nothing else failed.

The flag fires when a list mixes a 2xx with 401, 403, 404 or 407. A 2xx mixed with another refusal, such as [200, 405] or [200, 400], also accepts both outcomes and is not flagged: on the same run with [200, 405] no Non-asserting: line was printed. Do not write either.

The statuses Veridor treats as a denial

There is no single list. Each part of Veridor asks a different question, and uses the statuses that answer it:

Where Which statuses What follows
A declaration accepting only these is denial-only 401, 403, 404, 405, 407 A GET, HEAD or OPTIONS request gets a liveness control
Boundary coverage counts these as an authorization denial 403, 404 by default; replace with a top-level "coverage": {"denial_statuses": [...]} An operation with such an expectation counts as covered
Boundary coverage counts these as authentication only 401, 407 Never counted as coverage
A response with one of these, when the status is not in the declared list, is not an answer 429, 500–599, 407 (a proxy's) The probe is inconclusive, unless an excludes_marker identity's response carried the owner's marker: that is graded as any other mismatch below
Any other status outside 2xx means the target did not serve the request 400, 401, 403, 404, 405, 409, a 302, … A mismatch is unexpected_denial

401 for anonymous is worth declaring, and it is an authentication assertion: it never counts as authorization coverage.

vulnerable and unexpected_denial

When a response does not match the declaration, the verdict says which way it missed:

  • vulnerable: the target served the identity a 2xx the declaration did not allow, or the response carried another identity's marker (step 4). The marker case is vulnerable whatever the status, including when the status matched the declaration. The exception is when the fabricated credential below drew the same marker, which shows the marker is not unique to its owner. Then, if the status matched, the probe is inconclusive when the target refused the fabricated credential, and stays vulnerable, graded suspected, when the target served it. If the status did not match, a 2xx stays vulnerable, graded suspected, and a refusal is unexpected_denial.
  • unexpected_denial: the target refused, with a status outside the declared list and outside 2xx, other than the not-an-answer statuses above. Nothing was returned to the identity. It is still a failed declaration and the gate still fires on it: an absent route, an unshipped feature, an over-restriction, or a policy you wrote wrong.

In the run above, admin declared [200] on the invoice drew 404, an unexpected_denial. Whether that is the API refusing an administrator it should serve, or a declaration that should have said [404], is your call. The policy table says administrators do not read tenant invoices, so this page declares [404].

The full vocabulary is on verdicts and evidence grades.

Liveness controls: the credential Veridor fabricates

A denial-only expectation is satisfied by a boundary that refused the identity, and equally by a token that expired. To tell them apart, run sends one extra request per denial-only GET, HEAD or OPTIONS request, carrying a credential it fabricated, and says so before the first request:

veridor: liveness controls -- 3 extra request(s) carrying a credential Veridor fabricated, one per GET, HEAD or OPTIONS request whose declaration accepts only denial statuses. …

If the target answers your identity exactly as it answers that fabricated credential, and nothing else in the run proves the identity's credential live, the denial is reported as unproven. Step 5 shows what that looks like and the one-line fix. The fabricated value authenticates to nothing. --no-prove-liveness skips these requests and the check that depends on them; see the liveness controls.

If you also hold a real credential that has been revoked, you can declare it as one more identity with the denial you expect. Veridor treats it like any other identity.

Step 4: plant markers so a crossing is confirmed

With status lists alone, the first run against the vulnerable demo finds both leaks, and can say nothing about whether data moved:

veridor run --config veridor.json
Veridor run 20261110T044039Z-7e58a8: 9/11 probes matched their declaration, 2 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18801
Regression diff: 2 new, 0 resolved, 0 unchanged
Evidence: 0 confirmed, 0 suspected, 2 ungraded, 0 escalated since the baseline
…
- [vulnerable] invoice-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]).
…
- [vulnerable] export-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]).

Exit 1. Both are ungraded: a 200 where you declared 404 is a disagreement, not yet evidence that the other tenant saw anything.

A marker closes that gap. Generate one:

veridor new-marker
vdr-oAb0N6_SeA8-fNi2dv29guFM

Store it, through your own application, in a record owned by that identity, in a field the API returns: a note, a reference, a display name. Then declare it on the identity. The demo has already planted a marker in each tenant's data (inv-100 and the acme export for owner, inv-200 for other_tenant), so the file uses those:

"identities": {
  "owner": {
    "headers": {"Authorization": "Bearer ${VERIDOR_OWNER_TOKEN}"},
    "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
  },
  "other_tenant": {
    "headers": {"Authorization": "Bearer ${VERIDOR_OTHER_TENANT_TOKEN}"},
    "marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
    "marker_probe": {"method": "GET", "path": "/api/invoices/inv-200"}
  },
  "admin": {"headers": {"Authorization": "Bearer ${VERIDOR_ADMIN_TOKEN}"}},
  "anonymous": {"headers": {}}
}

Then say, per check, whose marker must and must not appear:

"expect": {
  "owner": {"status": [200], "contains_marker": "owner"},
  "other_tenant": {"status": [404], "excludes_marker": "owner"},
  "admin": {"status": [404], "excludes_marker": "owner"},
  "anonymous": {"status": [401]}
}

The export check gets the same contains_marker on owner and excludes_marker on other_tenant. The audit log returns no tenant data, so its check stays status-only.

The two keys work as a pair, and both name the marker's owner, never the value:

  • contains_marker on the owner is the control: it proves this path can return the marker at all. Veridor refuses to load an excludes_marker without a control, either this one in the same check or a marker_probe on the owner.
  • excludes_marker on everyone else is the assertion: their response must not carry it.

marker_probe is a request the identity should be served, sent once per run. It proves that identity's marker is live and its credential is real. other_tenant is only ever asserted denied in this policy, so against a target that holds the boundary, nothing else in the run could tell its denials from an expired token. Step 5 shows that case.

Markers are validated strictly (24 to 128 characters from A-Z a-z 0-9 _ -, no repetition patterns, no marker inside another), and a configuration that would send a marker it later matches is refused. The rules are in the configuration reference.

The same run against the vulnerable demo, with markers:

veridor run --config veridor.json --json vulnerable-run.json
Veridor run 20261110T044040Z-0ac1ab: 9/11 probes matched their declaration, 2 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18801
Regression diff: 2 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 1 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 'owner''s marker: 'owner''s declared marker was observed in 'other_tenant''s response. That is proof the string crossed, and proof of data flow insofar as the marker is unique to 'owner''s data. …
- [vulnerable (suspected)] export-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]). 'owner''s marker was not observed (response did not contain it), so data exposure is unproven.
…

Exit 1. The grades now separate the two leaks. On the invoice, the owner's marker reached the other tenant: confirmed. On the export, the other tenant was served a 200 whose body did not carry the owner's marker (the vulnerable demo hands it an empty row list): suspected. Both are failed declarations; only one moved the owner's data. What confirmed does and does not establish is on verdicts and evidence grades.

The marker also feeds map

Add "marker_owner": "owner" to the matrix section and the map command from step 2 now raises the invoice crossing as a candidate:

veridor map --config veridor.json --openapi openapi.json --promote candidates.json
…
1 candidate(s), ordered by counted observations only (no score, no confidence number):

[marker_reach] candidate -- other_tenant on GET /api/invoices/{id} [specimen owner_invoice]
    other_tenant reached GET /api/invoices/{id} and the response carried
    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.
…
Wrote 1 promotion stub(s) to candidates.json

candidates.json holds this stub, shown here without its placeholder name:

{
  "id": "candidate-marker-reach-ef4f779e",
  "request": {"method": "GET", "path": "/api/invoices/inv-100"},
  "expect": {
    "owner": {"status": [200], "contains_marker": "owner"},
    "other_tenant": {"status": [403, 404], "excludes_marker": "owner"}
  }
}

It is a hypothesis, not a policy. The [403, 404] is the default denial pair, not something Veridor observed, and the stub names only the two identities the candidate is about. Pasted into checks unedited, it is graded like any other check, and run prints an Unreviewed: block above the findings, followed by the stub's id and placeholder name:

Unreviewed: 1 check(s) still carry the name `veridor map --promote` wrote, so they are machine-generated hypotheses graded as if you had declared them. A candidate is not a finding; edit or delete them:
  - candidate-marker-reach-ef4f779e: …

Give it a real name, and decide every expectation in it, or delete it. See --promote writes stubs, not checks.

Step 5: run it, read the verdicts, choose --fail-on

Read the summary before the findings

Each run prints the counts by verdict, the Target: it measured, the regression diff against --baseline, the evidence grades and the two scope lines, with any disclosures (Non-asserting:, Unproven denials:, Unreviewed:) among them; then one entry per finding with what was observed, what was declared, and what to do. Check the Target: line first: the verdict above it is a verdict about that host and nothing else.

Choose the gate

--fail-on decides which findings exit 1. Measured on the vulnerable demo, with vulnerable-run.json from step 4 as the baseline (so both findings are unchanged) where a baseline is given:

Command Exit
veridor run --config veridor.json --baseline vulnerable-run.json --fail-on new 0
veridor run --config veridor.json --baseline vulnerable-run.json (default new+confirmed) 1
veridor run --config veridor.json --fail-on confirmed 1
veridor run --config veridor.json --fail-on none 0

Under --fail-on new the run still names what it is ignoring:

Regression diff: 0 new, 0 resolved, 2 unchanged
  Suppressed by the baseline: 2 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]

Keep the default unless you have a reason. new alone is decided by a file in your repository, so a change that adds a finding to the baseline turns a live leak green; new+confirmed still fails on anything a marker proved. confirmed alone would not fail on the suspected export leak above. Why the default is the union has the argument; exit codes has every code.

Fix the target and run again

Restart the demo without --vulnerable, and run the same file against the step-4 baseline:

veridor run --config veridor.json --baseline vulnerable-run.json --json fixed-run.json
Veridor run 20261110T044049Z-ac4649: 11/11 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18801
Regression diff: 0 new, 2 resolved, 0 unchanged
…
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.

Exit 0. Eleven probes: four on the invoice, three on the export, four on the audit log. The Pass: line is the bound on this result.

A green run can still carry unproven denials

Run the status-only file from step 3, saved as status-only.json, against the fixed demo:

veridor run --config status-only.json
Veridor run 20261110T044049Z-85cb0f: 11/11 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18801
Regression diff: 0 new, 0 resolved, 0 unchanged
Unproven denials: 3 expectation(s) held against a denial that a credential Veridor fabricated received too, so they did not establish who was denied.
  - invoice-tenant-isolation / other_tenant: 'other_tenant' received HTTP 404 on this request, and so did a credential Veridor fabricated that the target cannot have recognised. …
    Give 'other_tenant' a marker_probe naming something it SHOULD be served, or declare a check where it is served and the fabricated credential or an identity with no credential is refused on the same request. …
…

Exit 0 under the default flags. With --require-complete the same run exits 3. other_tenant is denied everywhere in that file and served nowhere, so its 404s are equally well explained by an expired token. The marker_probe added in step 4 is the fix, and the step-4 file prints no such block. Use --require-complete in CI so this cannot pass silently; see --require-complete.

Step 6: prove the checks can fail

A green run says the target agreed with you. It does not say any check could have disagreed. veridor mutate rewrites your expectations one at a time, re-grades the observations, and reports every mutant your file failed to kill. Run it against the fixed demo:

veridor mutate --config veridor.json
Veridor mutation -- this will re-issue your 12 declared request(s) to http://127.0.0.1:18801,
  plus up to 500 additional probe(s) of its own.
…
Mutation score: 0.93  (25 killed / 27 generated)
  survived 2  aberrant 0  errored 0  not applicable 5  skipped 6
  0.83 of 30 generated + limited (3 could not be evaluated). A mutation score is only as wide as the mutants it could evaluate.
…
  [SURVIVED] M6 sham-credential -- check 'audit-admin-role', identity owner
…
      observed: HTTP 403 both for 'owner' and for a credential fabricated this
      run
…
  [SURVIVED] M6 sham-credential -- check 'audit-admin-role', identity other_tenant
…
LIMITS ON THE EVIDENCE (4) -- not scored, and not survivors: nothing here was measured either way
  invoice-tenant-isolation
      other_tenant: this operation answers HTTP 404 both to this identity and
      to a credential it could not have recognised, which is what hiding
      existence uniformly looks like. …
…
  audit-admin-role
      this check's only evidence is the status code. …
…
Per check:
  invoice-tenant-isolation                  1.00  (10/10)
  export-tenant-isolation                   1.00  (7/7)
  audit-admin-role                          0.80  (8/10)  2 to read above

Exit 1: at least one survivor. Nothing in this report is a finding about the target. Read it as a review of the file:

  • Both tenant checks killed every mutant. Their markers and narrow status lists mean a regression in either direction changes a verdict.
  • The audit check has two survivors. The demo answers a token it does not recognise with 403 on the audit log, the same 403 it gives owner and other_tenant. Within that check, nothing distinguishes "refused because not an administrator" from "refused because the token is dead". In this file both credentials are proven live elsewhere (the owner's marker on the invoice, the other tenant's marker_probe), which is why run reported no unproven denial; mutate judges each check on what that check observed. Against your own API, a survivor like this is a question to answer: does the role check run before or after the token is validated?
  • The limits are not survivors. A target that answers 404 to everyone but the owner cannot show from outside who the caller was, and a status-only check carries no data-flow evidence.

Mutation re-issues your requests and sends some of its own, so point it only where run may go. mutate describes each operator.

Step 7: keep it current

A policy file goes stale the way tests do: the API grows, a role is added, a route moves. Four habits keep it honest.

Commit a baseline and gate against it

Commit the run document from a known-good run (fixed-run.json above), and pass it as --baseline in CI. Here the target was broken again, and the same file run against that baseline:

veridor run --config veridor.json --baseline fixed-run.json
Veridor run 20261110T044110Z-c99886: 9/11 probes matched their declaration, 2 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18801
Regression diff: 2 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 1 suspected, 0 ungraded, 0 escalated since the baseline
…

Exit 1. Do not pass .veridor/latest.json as the baseline: every run rewrites it, and Veridor refuses it with exit 2. Copy the run to a file of its own. The baseline diff explains how findings are matched.

Record the access matrix and watch it drift

Checks cover what you declared. Drift covers what changed elsewhere in the grid. Against the fixed demo, record a matrix:

veridor map --config veridor.json --openapi openapi.json --matrix baseline-matrix.json

Exit 0. After the target broke again:

veridor map --config veridor.json --openapi openapi.json --baseline baseline-matrix.json --fail-on-drift opened
Access-matrix drift — 2 change(s), 2 that mean more access
  baseline 20 cells · current 20 cells · 20 comparable
  drift saw 60% of the grid: 12 of 20 comparable cell(s)
     were measured in BOTH runs. …
  MORE ACCESS THAN THE BASELINE
    [reach_opened] GET /api/invoices/{id} other_tenant [specimen owner_invoice]
        refused -> reached
…
    [marker_appeared] GET /api/invoices/{id} other_tenant [specimen owner_invoice]
        absent -> present
…
  Exit 1: you passed --fail-on-drift 'opened' and the matrix moved. Discovery did not decide this; your drift policy did.

A drift entry is an observation, not a verdict: an opened reach may be a feature you shipped. When it is not, declare a check for it. Drift is blind to anything that was already open when you recorded the baseline; see what drift is blind to.

Measure what the file does not cover

veridor coverage reads the file and the specification and sends nothing, so it needs no credentials:

veridor coverage --config veridor.json --openapi openapi.json
Authorization boundary coverage: 2/5 operations (40.0%)
…
Corroborated: 2/5 operations
…
3 operations have no cross-identity denial asserted:
  GET    /api/invoices
  DELETE /api/invoices/{id}
  POST   /api/admin/audit/export

Diagnostics (1):
  unmatched_check  check 'export-tenant-isolation' GET /api/exports/acme.json matched no operation; nearest: /api/invoices/{id}

Exit 0. Each uncovered operation is a decision: declare a check, or decide it has no per-identity boundary. The diagnostic says the export route is not in the specification at all, so coverage cannot count it; the check still runs. Coverage says a denial is asserted, never that it is enforced (coverage is asserted, never enforced). run --openapi openapi.json reports the same figure alongside a run.

Review the file like code

The configuration is the policy, so a change to it is a policy change. In review, ask of every diff:

  • Does each new or changed expectation match the policy in words? A wrong expectation and a real vulnerability produce the same finding.
  • Did a status list get wider? [404] becoming [200, 404] silences a check.
  • Did a check, an identity, a marker or an excludes_marker disappear?
  • Did a finding id appear in the committed baseline? That accepts a finding.
  • Is every credential still a ${VERIDOR_*} reference?
  • Does any check still carry the placeholder name from --promote, or the file still carry _generated_by from init?

Checklist

  • [ ] The policy is written in words first: who may do what, per operation.
  • [ ] At least two identities, one per side of each boundary: every role, at least two tenants, and anonymous with "headers": {}.
  • [ ] Every credential is a ${VERIDOR_*} reference; no literal secret in the file.
  • [ ] Operations come from map reach, init and coverage, not memory alone; every {id} is a real id.
  • [ ] Every check names every identity whose answer matters, and every status list is as narrow as the policy. None mixes a 2xx with a refusal.
  • [ ] Each identity's own data carries a marker from veridor new-marker; each excludes_marker has a contains_marker control or a marker_probe.
  • [ ] Every identity asserted only to be denied has a marker_probe, so no Unproven denials: block appears.
  • [ ] No Non-asserting: or Unreviewed: block in the run output.
  • [ ] --fail-on is the default new+confirmed, and CI passes --require-complete.
  • [ ] --read-only is passed for any target you did not provision.
  • [ ] veridor mutate has been run, and every survivor read and answered.
  • [ ] A baseline run and a baseline matrix are committed, and changes to them and to veridor.json are reviewed like code.

Next