map — what would a compromise reach?¶
veridor run answers questions you already thought to ask. map answers the
one you did not: given the routes this API actually has, what can each identity
reach, and where do the answers contradict each other?
Nothing is declared. map needs a route inventory, two credentials, and one
real object id per templated route.
What it needs¶
- An OpenAPI 3 or Swagger 2 JSON specification.
--openapiis required. Veridor never discovers routes. - Two identities. This is the one floor that does not move: a single principal cannot express a boundary at all.
- One
matrix.specimensentry per templated path. Without it the operation is not probed at all and every one of its cells readsunmeasured.
{
"version": 1,
"target": {"base_url": "http://127.0.0.1:8765"},
"identities": {
"invoice_owner": {"headers": {"Authorization": "Bearer ${OWNER_TOKEN}"},
"marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"},
"other_tenant": {"headers": {"Authorization": "Bearer ${OTHER_TOKEN}"}},
"anonymous": {"headers": {}}
},
"matrix": {
"specimens": {"/api/invoices/{id}": [{"label": "owner_invoice",
"values": {"id": "inv-100"}}]},
"marker_owner": "invoice_owner",
"peer_groups": {"tenants": ["invoice_owner", "other_tenant"]},
"privilege_order": ["anonymous", "other_tenant", "administrator"],
"unauthenticated": "anonymous"
}
}
An identity meant to be anonymous declares "headers": {} explicitly. Writing
credentials one level too high — {"owner": {"Authorization": "..."}} instead
of {"owner": {"headers": {"Authorization": "..."}}} — is refused, because as
written that identity would send nothing and be probed anonymously under its
own name.
Run it¶
The output below is against the vulnerable demo target — the same
python3.12 scripts/demo_server.py --vulnerable the
quickstart switches on. Start it with
that flag first:
veridor map --config fixtures/veridor.map-demo.json --openapi fixtures/openapi.demo.json --promote candidates.json
Against the patched demo, this report is shorter and still correct
Without --vulnerable, other_tenant reaches nothing, map reports
0 candidate(s), and the example below will read as though it contradicts
this page. It does not: map reports what it observed, and against a target
whose boundary holds there is no inconsistency to report.
The report has four parts. Abridged:
Veridor access matrix -- this will issue 12 requests to 127.0.0.1:8765.
operations: 3 (of 5 in the spec; 2 skipped as non-read-only)
identities: 4 specimens: 1 per operation (max)
methods: GET (read-only)
rate: 2 req/s, sequential
budget: 500
estimated: 6s (rate limiter only; the run stops at 30m00s)
target: loopback (http://127.0.0.1:8765)
8 cells are recorded as unknown / skipped_mutating -- unknown, never refused.
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]
Authorization boundary coverage: 2/5 operations (40.0%)
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
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.
Exit code 0 whatever the report says about access. The exceptions are
about measurement, not findings: a grid in which not one probed cell was
measured exits 3 (see exit codes).
1. The access matrix¶
One request per operation, per identity, per specimen. Every cell of the grid is
accounted for — a reach value, or an explicit unknown_reason. Nothing is
silently omitted, because a missing cell reads as "nothing there".
| Reach | Recorded when |
|---|---|
reached |
the status was a success |
refused |
the target refused: 401, 403, 404, 405, 501 (405 and 501 as method_not_supported, checked ahead of the 5xx sweep) |
indeterminate |
the status was neither, such as a 3xx. Printed as unresolved |
unknown |
nothing was measured; carries an unknown_reason. Printed as unmeasured |
The reasons are a closed set. Five of them mean the plan never sent the request
(not_probed, skipped_mutating, skipped_budget, skipped_no_specimen,
skipped_ignored) and five mean the target did not answer (transport_error,
rate_limited, server_error, proxy_auth_required, aborted). The
distinction decides what you can do about it: re-running helps with the second
group and cannot help with the first.
A 429, a 5xx and a proxy's 407 are unknown, never refused. The
request was throttled, the handler fell over, or a proxy asked for its own
credentials before the request reached the target; nothing was established in
either direction, and a grid of refusals reads exactly like a boundary that
holds.
2. Blast radius per identity¶
Which operations each identity was observed to reach.
Two bounds, stated inline, because the number invites two wrong readings
Blast radius is a lower bound on observed reach, not a measure of exposure. And it counts operations reached, never objects: an operation counted as reached may expose one record or every record. Veridor probes the specimens you supplied and never enumerates identifiers.
When any cell went unmeasured, no percentage is rendered — a share over an incomplete denominator reads as the answer. The report prints the counts and says why the share is missing.
3. Boundary coverage¶
The same measurement veridor coverage makes, against the same specification
the matrix was built from.
Coverage measures whether a cross-identity denial is ASSERTED for each
operation. It does not measure whether authorization is ENFORCED. An
operation counts as covered when at least one check asserts a denial-only
outcome for at least one identity — which is a fact about your configuration,
not about your target. An expectation of 401 alone is an authentication
assertion and never counts. Expectations that accept both success and refusal
assert nothing and are reported as diagnostics rather than counted.
4. Anomaly candidates¶
Inconsistencies between the observations, ranked by counted evidence. There is
no score and no confidence percentage, because a fabricated 0.86 invites you
to read it as "86% likely real" and nothing here supports that claim.
A candidate is not a finding¶
This is the line the whole command is built around. A candidate is an
inconsistency between observations and a hypothesis to declare and test. It
produces no SARIF result, never enters latest.json or .veridor/runs, and
never changes an exit code.
Six rules run, ordered by how few assumptions each makes:
| Tier | Rule | The shape it looks for |
|---|---|---|
| 0 | marker_reach |
one identity's planted marker was observed in another identity's response |
| 1 | unauthenticated_reach |
the identity designated unauthenticated returned a success status |
| 2 | inverted_guard |
a lower-privilege identity reached what a declared higher one did not |
| 3 | peer_divergence |
several members of a declared peer group reached one shared object |
| 4 | sibling_asymmetry |
an identity refused on nearly every member of a path family reached one |
| 5 | method_asymmetry |
two verbs on the same template disagreed for the same identity |
Every rule declares its own false-positive modes and the report prints them under each candidate — including where the rule's own exclusions mean a real problem would not be raised. Where a rule declined to raise a candidate, the report counts the suppression by reason, so a tightened rule that quietly stops working does not look like a quiet target.
--promote writes stubs, not checks¶
--promote candidates.json writes each candidate as a deliberately incomplete
check. The name is a TODO and you confirm which identity should be allowed,
because what the boundary is is your decision, not Veridor's.
{
"checks": [
{
"id": "candidate-marker-reach-bfe2df26",
"name": "TODO: describe the boundary you are asserting",
"request": {"method": "GET", "path": "/api/invoices/inv-100"},
"expect": {
"invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
"other_tenant": {"status": [403, 404], "excludes_marker": "invoice_owner"}
}
}
]
}
If a stub is pasted in unedited, run grades it like any other
declaration — it cannot audit a declaration and never claims to — but it says
so. The TODO name travels with the finding into the console summary and into
SARIF (checkName, plus an unreviewedPromotionStubs run property), and run
prints an Unreviewed: block above the findings naming every check that still
carries it. A machine-generated hypothesis must not arrive looking hand-written.
--dry-run sends nothing¶
It prints the plan, the safety posture and the time estimate, then stops. Run it first against anything you did not provision yourself.
operations: 3 (of 5 in the spec; 2 skipped as non-read-only)
identities: 4 specimens: 1 per operation (max)
estimated: 6s (rate limiter only; the run stops at 30m00s)
8 cells are recorded as unknown / skipped_mutating -- unknown, never refused.
--dry-run: nothing was sent.
--include and --ignore match by path prefix¶
Not as globs, and not as regexes.
- A match selects the route and everything beneath it, so
--include 'GET /api/invoices/{id}'also selects/api/invoices/{id}/lines. *is an ordinary character.{id}must be spelled the way the specification spells it.- An
--includelist where every pattern misses is refused before a socket opens, because an empty selection produces an empty grid rather than a clean one. --ignoreis repeatable and adds tomatrix.ignore.
The matrix section in full¶
specimens is the only key map needs. The rest tell the candidate rules what
they cannot observe — who is a peer of whom, and which way privilege runs. Each
one switches on rules that stay silent without it. Veridor never infers these
from behaviour: a wrong grouping does not produce fewer candidates, it produces
confidently wrong ones.
| Key | What it does | Without it |
|---|---|---|
specimens |
a real object id per templated path, one entry per {placeholder} |
the operation is not probed at all and every cell reads unmeasured |
marker_owner |
which identity's planted marker responses are scanned for | no cell is graded marker_scan: present, so marker_reach cannot fire and drift loses its strongest signal |
peer_groups |
identities that should be able to reach the same things | peer_divergence and sibling_asymmetry stay silent |
privilege_order |
least privileged first | inverted_guard stays silent: it has no way to know that admin outranks a tenant |
unauthenticated |
the identity that carries no credentials | unauthenticated_reach stays silent |
ignore |
operations never to probe, as METHOD /path/template |
everything the specification declares is in scope |
Full key-by-key detail is in the configuration reference.
Safety envelope¶
The matrix is a scanning-shaped workload and is treated as one. The
safety posture page has the whole envelope; the parts that
change what map measures:
- Read-only by default. Only
GETandHEADare sent. A mutating verb must be named literally with--allow-mutating-method DELETE(never a wildcard) and accompanied by--acknowledge-mutatingverbatim. What you do not opt in to is not probed at all: those cells are recordedunknown / skipped_mutating, neverrefused, and the count is printed before the run starts. - The plan is refused before the first request.
--max-requestsdefaults to 500 and is checked against the built plan. 20,000 is an absolute ceiling no flag raises. - Rate limited and sequential.
--ratedefaults to 2 req/s and is capped at 10 req/s. There is no flag that disables the limiter. - The run has a wall clock.
--max-run-secondsdefaults to 1,800s and is capped at 7,200s. It is a stop, not a budget: the matrix reports a partial grid, and the cells it never reached readunknown, never "no access". Rate × wall clock is the real request ceiling, and it usually binds first — at 2 req/s over 30 minutes that is 3,600 requests. - A non-loopback target must be typed out.
--acknowledge-target "I am authorized to test HOST", verbatim. That is friction, not a security boundary. - Requests stay under the base path. A specimen value or path that would
leave it,
../../adminor any encoded spelling of a dot segment, is refused before the first request, asrunrefuses it; it used to be planned as..%2F..%2Fadmin. A proxy named byhttp_proxyorhttps_proxycarries the requests only for a target that is not loopback, andmapsays so on stderr first (proxies). --rate nan,--max-run-seconds nanand--timeout nanare refused with exit2. NaN compares false with everything, so it passed every ceiling: the first two crashed with exit70, and--timeout nanran a matrix in which no request could complete (recorded in the CHANGELOG).- A session-backed identity is refused, not guessed at.
mapbuilds every probe from the identity's staticheaders; it has no cookie jar and does not log in. An identity that authenticates through asessionblock would be probed anonymously and its anonymous reach recorded under its name — a blast radius smaller than the truth, which is the one direction this tool must never be wrong in.maprefuses before it opens a socket and tells you to userun, which does log in. See sessions.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
it produced a map, whatever it found |
1 |
you passed --fail-on-drift and the matrix moved against your baseline |
2 |
refused — an over-budget plan, a missing acknowledgement, an off-target path, --fail-on-drift without --baseline, an output directory it cannot write — or the inputs would not load |
3 |
not one probed cell was measured (a redirect does not count: Veridor never follows one); or --require-complete was passed and some cell was not; or, under --fail-on-drift opened or any, not one cell was measured in both this run and the baseline |
Nothing map discovers can produce a non-zero code: only a diff against a
baseline you recorded, or a grid it could not measure. Exit 3 is not about a
finding either: a grid where nothing was measured records nothing, so it is not
a baseline and it is not a pass. Measured for this page on an earlier tree (before 38025ed),
against a closed port and then against a listener answering 302 to
everything:
veridor: not one of 12 probed cell(s) was measured, so this matrix records nothing.
It is not a baseline and it is not a pass. Check the target URL, the credentials,
and that the target is up.
veridor: not one of 12 probed cell(s) was measured, so this matrix records nothing.
It is not a baseline and it is not a pass. 12 of them were answered with a
redirect, which Veridor does not follow, so nothing behind them was observed: a
login wall, an SSO proxy, a maintenance page, or http:// for an https-only host
are the usual causes. Check the target URL, the credentials, and that the target
is up.
Both exited 3. What a drift gate does with a baseline like that is on
drift.
Next¶
- drift — what a second
mapmeans read against the first. - run — turn a candidate into a declared expectation and gate on it.
- claims and limits — what none of these numbers establish.