Skip to content

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. --openapi is 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.specimens entry per templated path. Without it the operation is not probed at all and every one of its cells reads unmeasured.
{
  "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:

python3.12 scripts/demo_server.py --vulnerable
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

veridor map --config veridor.json --openapi openapi.json --dry-run

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 --include list where every pattern misses is refused before a socket opens, because an empty selection produces an empty grid rather than a clean one.
  • --ignore is repeatable and adds to matrix.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 GET and HEAD are sent. A mutating verb must be named literally with --allow-mutating-method DELETE (never a wildcard) and accompanied by --acknowledge-mutating verbatim. What you do not opt in to is not probed at all: those cells are recorded unknown / skipped_mutating, never refused, and the count is printed before the run starts.
  • The plan is refused before the first request. --max-requests defaults to 500 and is checked against the built plan. 20,000 is an absolute ceiling no flag raises.
  • Rate limited and sequential. --rate defaults 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-seconds defaults 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 read unknown, 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, ../../admin or any encoded spelling of a dot segment, is refused before the first request, as run refuses it; it used to be planned as ..%2F..%2Fadmin. A proxy named by http_proxy or https_proxy carries the requests only for a target that is not loopback, and map says so on stderr first (proxies).
  • --rate nan, --max-run-seconds nan and --timeout nan are refused with exit 2. NaN compares false with everything, so it passed every ceiling: the first two crashed with exit 70, and --timeout nan ran a matrix in which no request could complete (recorded in the CHANGELOG).
  • A session-backed identity is refused, not guessed at. map builds every probe from the identity's static headers; it has no cookie jar and does not log in. An identity that authenticates through a session block 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. map refuses before it opens a socket and tells you to use run, 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 map means 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.