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:
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:
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: 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"
}
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¶
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:
idis unique and limited to letters, digits,.,_and-. It is a fingerprint input, so renaming a check re-keys its findings against a baseline.expectnames at least two identities, each declared underidentities.statusis a list of the statuses you accept, each100–599. A bare integer is read as a one-item list.requesttakesmethod,path,headers,jsonandbody, and nothing else except_annotations.methodis upper case.- Anything other than
GET,HEADorOPTIONSwrites to the target. Pass--read-onlyto 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 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 a2xxthe declaration did not allow, or the response carried another identity's marker (step 4). The marker case isvulnerablewhatever 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 isinconclusivewhen the target refused the fabricated credential, and staysvulnerable, gradedsuspected, when the target served it. If the status did not match, a2xxstaysvulnerable, gradedsuspected, and a refusal isunexpected_denial.unexpected_denial: the target refused, with a status outside the declared list and outside2xx, 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 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:
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_markeron the owner is the control: it proves this path can return the marker at all. Veridor refuses to load anexcludes_markerwithout a control, either this one in the same check or amarker_probeon the owner.excludes_markeron 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 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:
…
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 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 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 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
403on the audit log, the same403it givesownerandother_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'smarker_probe), which is whyrunreported no unproven denial;mutatejudges 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
404to 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 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:
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:
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_markerdisappear? - 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_byfrominit?
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
anonymouswith"headers": {}. - [ ] Every credential is a
${VERIDOR_*}reference; no literal secret in the file. - [ ] Operations come from
mapreach,initandcoverage, 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
2xxwith a refusal. - [ ] Each identity's own data carries a marker from
veridor new-marker; eachexcludes_markerhas acontains_markercontrol or amarker_probe. - [ ] Every identity asserted only to be denied has a
marker_probe, so noUnproven denials:block appears. - [ ] No
Non-asserting:orUnreviewed:block in the run output. - [ ]
--fail-onis the defaultnew+confirmed, and CI passes--require-complete. - [ ]
--read-onlyis passed for any target you did not provision. - [ ]
veridor mutatehas been run, and every survivor read and answered. - [ ] A baseline run and a baseline matrix are committed, and changes to them
and to
veridor.jsonare reviewed like code.
Next¶
- Configuration reference: every key and refusal.
run: the gate in full.- Sessions and CSRF: identities that log in with cookies.
- Multi-step sequences: boundaries that need state across requests.