Safety posture¶
You are deciding whether to point this at something. This page is what Veridor will and will not send, which gates are opt-in, and where the ceilings are.
The short version: run sends the requests you wrote, once each, per
identity, plus its own marker probes and liveness controls, and it is not
throttled. map fans a specification out across every identity, which is
categorically closer to scanning than to testing, so it carries a budget, a rate
limiter, a wall clock and two acknowledgements. mutate takes map's posture
on budget and rate rather than run's, but not its read-only default or its
target acknowledgement.
Only against systems you own or are explicitly authorised to test
Veridor prints that sentence before every matrix. Nothing on this page is a substitute for having permission.
map is read-only by default¶
map sends GET and HEAD. Nothing else, ever, without two independent
opt-ins. run and mutate are not read-only by default: they send what your
configuration declares, and veridor init scaffolds DELETE and POST
checks.
A mutating verb must be named literally, and accompanied by an exact acknowledgement string:
veridor map --config veridor.json --openapi openapi.json --allow-mutating-method DELETE --acknowledge-mutating "I have authorization to send mutating requests to this target"
Miss either half and nothing is sent:
veridor: mutating methods (DELETE) require mutating_acknowledgement to be exactly 'I have authorization to send mutating requests to this target'. Nothing has been sent.
veridor: allow_mutating_methods entry '*' is a wildcard. Every mutating verb must be named literally, e.g. allow_mutating_methods={'DELETE'}.
Two independent gates, so one stray flag opens neither. There is no wildcard. Mutating requests then get their own budget of 50 within the total, and every one is listed by name before the first is sent.
What you do not opt in to is not probed at all. Those cells are recorded
unknown / skipped_mutating — never refused — and the count prints in the
banner before the run starts. A cell nobody measured must never read as a cell
that was closed.
For run and mutate, the equivalent is a refusal rather than an opt-in.
--read-only fails the load with exit 2 if any request the configuration
can issue — check, sequence step, teardown, login, or marker probe — uses a
method outside GET, HEAD and OPTIONS. It fails before a single request is
sent. This is the recommended default for any target you did not personally
provision.
The plan is refused before the first request¶
--max-requests defaults to 500 and is checked against the built plan, not
enforced by counting as it goes. The refusal reports the number it refused and
how to narrow it, and nothing has gone on the wire.
20,000 is an absolute ceiling that no flag raises. An override with no ceiling is not a safety control.
The three numbers¶
These are map's.
| Control | Default | Ceiling |
|---|---|---|
Rate (--rate) |
2 req/s, sequential | 10 req/s |
Plan size (--max-requests) |
500 | 20,000 |
Wall clock (--max-run-seconds) |
1,800s (30m) | 7,200s (2h) |
There is no flag that disables the rate limiter. Requests are sequential; there is no concurrency to configure.
Rate × wall clock is the real ceiling, and it usually binds first. At the
default 2 req/s, the default 30-minute stop allows 3,600 requests — far below
20,000. An ordinary 1,000-operation specification across five identities already
exceeds it. --dry-run prints the estimate beside the stop and says so when the
plan cannot finish.
The wall clock is a stop, not a budget. When it fires, the matrix reports a
partial grid and the cells it never reached read unknown, never "no access".
Two more bounds worth naming: the per-request timeout is capped at 60s and is not adjustable upward — an unbounded timeout is how a plan whose banner promised minutes runs for hours against a half-open socket. And a maximum of 3 specimens per operation is what stops the matrix becoming an identifier enumerator by configuration drift.
--dry-run sends nothing¶
Run it first against anything you did not provision yourself.
Veridor access matrix -- this will issue 12 requests to 127.0.0.1:8934.
Use it only against systems you own or are explicitly authorised to test.
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)
timeout: 3s per request
target: loopback (http://127.0.0.1:8934)
8 cells are recorded as unknown / skipped_mutating -- unknown, never refused.
--dry-run: nothing was sent.
estimated covers the rate limiter only and explicitly excludes response time.
It is a floor, not a ceiling. The wall clock is the ceiling.
A non-loopback target must be typed out¶
veridor: staging.example.com is not a loopback or private address. Pass --acknowledge-target 'I am authorized to test staging.example.com'. This is friction, not a security boundary: the point is that you must type the hostname, so pointing the matrix at the wrong host is hard to do by accident. Nothing has been sent.
Say that plainly: friction, not a security boundary. It stops an accident, not an adversary. You have to type the hostname, so the wrong host is hard to reach by mistake.
mutate does not ask for this, and that is a decision rather than an oversight.
map fans out from a specification across every identity. mutate re-issues
the requests you declared in a configuration you wrote naming a
base_url — exactly what veridor run does — so gating it and not run would
be incoherent.
Requests stay on the target¶
Every request goes to the target you named. These would send it somewhere
else, so they are refused with exit 2 when the configuration (or a capsule)
is loaded, before anything is sent, in run, verify, map and sequence
steps alike:
- A path that becomes another URL.
/http://host/xreads as a path but becomes an absolute URL once its leading/is dropped to join it onto the target. It used to send every identity's credentials tohost. - A
.or..segment, plain, percent-encoded any number of times (a segment still decoding after eight rounds is refused too) or as overlong UTF-8 (%c0%ae), before a;path parameter (..;, which Tomcat and Jetty read as..), or beside an encoded/or\(..%2f,..%5c,..%252f, which a gateway that decodes them reads as../). Joined onto a base URL ofhttps://gateway/service-a/,/../service-b/adminused to go to/service-b/admin, another application behind the same gateway. An encoded/inside a name (group%2Fproject) is kept. Spellings only one server family reads, such as IIS's%u002eor a segment with trailing spaces, are not judged. - A
Hostheader, in any case and in any header map: checks, identities, marker probes, login steps, sequence steps, teardown, and as a sessioninjecttarget. urllib sends it in place of the URL's host, and every check above reads the URL, so behind a gateway that routes byHostit delivered the request, credentials included, to another virtual host. The host belongs inbase_url. - An unknown key in a request block.
params,query, a bareAuthorizationor a misspeltHeadersused to load and be dropped, so the request went out without them. A request takesmethod,path,headers,jsonandbody; anything else is refused unless it starts with_.
And every request is checked once more just before it is built: a URL without
the target's scheme, host and port is not sent, and neither is one outside the
target URL's path when that URL has one (with https://gateway/service-a/,
only /service-a and what is under it). A target URL with no path contains
requests to its origin and nothing narrower.
Proxies are the one exception you control. When http_proxy or
https_proxy (either case) is set for the target's scheme and no_proxy does
not cover its host, requests go through that proxy, and Veridor says so on
stderr before the first request. For an http:// target the proxy reads every
request, credentials included; for https://, a proxy that inspects TLS with
a certificate authority the machine trusts reads everything too, and Veridor
cannot tell which kind it is. The operating system's proxy settings are never
read, and a loopback target is always reached directly. The rules, the
refusals and a measured example are on
the configuration reference.
No redirects, no retries, no concurrency¶
- No redirects. A redirect is treated as an observed response and
credentials are never forwarded to the new location. A
302to a login page is the denial in every session-based application, and following it would turn a denial into a200. - No retries. A failed request is a failed measurement, and it is reported as one.
- No concurrency. Everything is sequential, sequences included, so there is no interleaved fixture state to reason about.
The run stops on signals that mean you are not welcome¶
| Signal | Threshold |
|---|---|
| Consecutive rate-limited responses | 3 |
| Cumulative rate-limited responses | 5 |
| Consecutive server errors | 5 |
| Consecutive transport failures | 10 |
Share of requests answered 429 or 5xx, once at least 20 have gone out |
20% |
The cumulative rate-limit rule is not a share, deliberately. A leaky-bucket
throttle answers a burst with a 429 and then refills, so its 429s stay
isolated and well under any percentage — and without a total cap such a target
gets ground through in full. Five separate "slow down" responses is not
ambiguous.
A 429 carrying Retry-After counts toward the same budgets as a bare one, so
a target that sends the header gets the same tolerance as one that does not —
never less. --on-retry-after stop abandons the grid on the first one instead.
Aborting leaves the cells it never reached unknown. It does not fabricate a
result for them.
Writing to the target¶
Sequencing makes Veridor a tool that writes. That is a categorical change and is treated as one.
Fixture setup creates and deletes objects in the target. This is for systems you own. Point it only at an ephemeral environment you can recreate: a per-run container, a seeded test tenant, a preview deploy. Never at production, and never at a shared staging system holding data anyone relies on.
- A run that issues a mutating request prints one banner on stderr before its
first request, and the run document records
mutation.verbsandmutation.requests_issued. The block is absent when nothing was written, so the artifact answers "did this run change my target?" without anyone having to reason about the config. - Teardown runs on every code path Veridor can see: a normal exit, an
error, Ctrl-C (SIGINT, exit
130) and SIGTERM (exit143), which is what a cancelled CI job, a Kubernetes pod termination and a Job'sactiveDeadlineSecondssend. SIGTERM takes the same path as SIGINT, so everyfinallyruns; a second SIGTERM during that teardown is ignored rather than cutting it short. A run stopped before its probes finish writes no run document.SIGKILLcannot be caught, so aSIGKILL, or a runner eviction that sends one, can still leave fixtures behind. Measured by Veridor's own slow-tier test, which sends each signal mid-sequence to a local server that records the teardown request. - A run whose results cannot be written is refused before its first request:
run,map,mutateandverifycheck that they can write their output directory and report destinations first, and exit2withNothing has been sentif not. Without that check, a write-mode run into an unwritable directory would change the target and then lose what it found. - If the attacker's own probe is mutating and succeeds — that being the vulnerability — the resulting state change is the attack's, not the fixture's, and Veridor does not attempt to undo it. It cannot know what happened, and guessing would need conditionals the configuration deliberately does not have.
- If losing an object in the target would matter to anyone, use
--read-only.
mutate re-issues every request you declared, DELETE included, and then issues
probes of its own on top. Its own envelope: --min-interval defaults to 0.5
(2 req/s, covering every request including the re-issued baseline),
--request-budget defaults to 500 for the additional probes, and
--max-requests defaults to 5,000 and bounds the whole plan. --min-interval 0
is allowed, for a fixture you own. --read-only and --max-requests apply at
both tiers; tier 2 runs through run's machinery, so it has no throttle and no
probe budget.
mutate --tier 2 executes commands you supply
Tier 2 mutates your target using an apply_command and a
restore_command you write. They run on your machine, as you, with your
environment — as an argv list with no shell, so nothing is expanded,
globbed, or interpreted, but they still run. It needs both --hooks and
--i-understand-tier-2-runs-my-commands. A hook with no restore_command
is refused: "I forgot the restore command" and "this target needs no
restore" must not be indistinguishable.
Credentials and markers¶
-
Credentials are not written down, within stated limits. Cookie values, captured tokens and login secrets exist only in memory for the duration of a run, and no cookie jar is ever persisted, under any flag. Header values are never written, and query values are redacted in every stored URL. Every value Veridor expands from a
${NAME}, wherever you put it (a path segment, a;param, the base URL, a body), is written and printed as its${NAME}: in run documents and their history copies, matrices, drift, blast radius, coverage, mutation reports, every report format, stdout and stderr, tracebacks included. That covers the value as written, percent-encoded,+-encoded, lower-cased as a host name is stored, and re-encoded as a query parameter name is stored. Before this, a${NAME}in a request path was written as its value to the run document and the GitLab SAST report. The limits:- a value shorter than 8 characters is left as it is, because it cannot
be told apart from ordinary text; the command names each such
${NAME}on stderr instead. Do not use a value that short for a secret; - a credential typed literally into a configuration, or into a flag such as
--base-url, is your own text: Veridor cannot recognise it, so outside headers and query values it is written wherever that text goes; - a finding id is a truncated SHA-256 over the check's request, path and body included, so a short or guessable value there can be confirmed from the id by guessing.
Measured for this page on an earlier tree (before
38025ed): a 19-character${VERIDOR_SEG}in a check's path reached the listener as its value, and the run document, the GitLab SAST report and both managed history files held${VERIDOR_SEG}and no copy of the value; a 7-character${VERIDOR_SHORT}was named on stderr instead. - a value shorter than 8 characters is left as it is, because it cannot
be told apart from ordinary text; the command names each such
-
Response bodies are never persisted. They are scanned in memory and discarded.
- The marker literal is never written to any artifact. A configuration that
would send a marker in the request it later matches is rejected outright — a
reflected marker cannot prove a leak — and that check inspects every byte
Veridor puts on the wire through the decodings a target may apply before
echoing: percent, plus, HTML entity,
\uXXXX, base64, hex. - An empty credential is refused. A
${NAME}that is set but empty, or only whitespace, stops the run with exit2before anything is sent, naming the variable. A withheld secret (a fork pull request gets an empty string, not an unset variable) would otherwise be sent as an empty credential, and the denial it drew would read as a finding about the target. Unset stays an error too. Measured: an emptyVERIDOR_OWNER_TOKENexited2with 0 requests at a recording listener; with it set, the same configuration sent 9. - A capsule carries no credential Veridor can recognise. It is built from
your configuration as written, before
${ENV_VAR}interpolation. Writing one is refused if an identity's credential header is a literal, and then the whole finished capsule is scanned: credential-looking headers (each cookie separately), sessioninjectvalues, every string or number anywhere beneath a credential-named key (password,accessToken,aws_secret_access_key, Okta'scredentials.password.value,{name: password, value: ...}pairs), credential-named parameters in query strings,;path parameters, form bodies and JSON text inside strings, anduser:pass@in any URL must all be${...}references. The refusal lists the JSON paths (at most ten), never a value, and endsNothing has been written.There is no override flag: a published bearer token cannot be recalled. Recognition is by name and position, so a secret under a name the rule does not know (apinorotpfield, say) is not caught: read the capsule before you send it. The Helm chart's render refusal applies the same rule, and a shared fixture holds the two to each other. - A capsule names only
VERIDOR_*variables. A reference to any other name is renamed on the way out (OWNER_TOKENinside identityinvoice_ownerbecomesVERIDOR_INVOICE_OWNER_OWNER_TOKEN), the renames are printed, andrequires_environmentlists what the verifier must set. - A session-backed identity is refused by
map, not guessed at.maphas no cookie jar and does not log in, so such an identity would be probed anonymously and the 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. It refuses before it opens a socket and tells you to userun.
Verifying a capsule someone sent you¶
A capsule is a file from someone else, and veridor verify <capsule> reads
credentials from your environment and sends them where the capsule says. So
it treats the capsule as untrusted input. Every refusal below is exit 2,
before anything is sent:
- Only a short list of options applies.
--base-url,--output-dir,--read-only,--json,--sarifand--no-prove-liveness. Any otherrunoption typed next to a capsule (--config,--report,--wait-for-target, ...) is refused by name rather than silently ignored. - Only
VERIDOR_*variables. Every${NAME}the capsule would expand, in its target, identities or check, must match^VERIDOR_[A-Z0-9_]+$. The set is derived from the content; the capsule's ownrequires_environmentis not trusted. The refusal names the variable and where it appears. --base-urlis required for a non-loopback target. A capsule names the reporter's target. Without--base-url, a capsule whose target is not loopback (127.0.0.0/8,::1or exactlylocalhost; an unexpanded${}counts as not loopback) is refused.--base-urlis required for any write, loopback included: an untrustedDELETEaimed at127.0.0.1can reach a local admin service. With--read-only, any request outsideGET,HEADandOPTIONSis refused, a session loginPOSTincluded.- Requests must stay on the target: no path that becomes another URL, no
.or..segment in any spelling above, noHostheader anywhere, no unknown request key (above). - The capsule's words cannot draw your screen. A
claimfield (any string in it, keys and nested values included), thescopeor the check'snamecarrying a terminal control (C0 or C1 controls, an escape sequence, a line break, a Unicode format control such as the bidi override U+202E) is refused, naming the field and the code point. A capsule whosescopebeganESC[2J ESC[Hused to clear the terminal and draw a verdict of its own over the real one. - The claim must be about the check it carries. A
claim.check_idthat is not the id of the capsule's check is refused, naming both: the verdict was read from findings under the claimed id, so a mismatch used to come outNOT_REPRODUCED, exit0, whatever the target did. - The claimed grade must be one Veridor writes:
n/a,ungraded,suspectedorconfirmed. The grade is quoted in the verdict's reason, and a 3774-character one of spaces and a copied session wrapped into aNOT_REPRODUCEDover a realCONFIRMED(recorded in the CHANGELOG). --jsonand--sarifmay not overwrite the capsule or managed history.
What it prints is bounded, and shown rather than obeyed. Everything
verify prints from a capsule or from its run, and every refusal that quotes
one, shows control characters escaped (\x1b, \u202e), shows each run of
blanks as one space, and cuts each quoted value at 120 characters; the
reporter's statement is cut at 600. A key of spaces and a copy of a genuine
session, padded to the terminal's width, used to wrap into a whole verdict
screen over the real refusal. The capsule's fields are printed above
Veridor's verdict line, which names the verdict and is followed only by the
Scope: sentence, Veridor's own and not the capsule's copy. A refusal line
longer than 1000 characters keeps its start and its end.
When nothing is refused, it prints the target it will contact, the
VERIDOR_* variables it reads, and the proxy the values go through when one
applies (only with --base-url: a loopback target is never proxied), then the
verdict: CONFIRMED (exit 1), NOT_REPRODUCED (0) or INCONCLUSIVE
(2). NOT_REPRODUCED needs a measurement behind it; anything short of one
is INCONCLUSIVE (exit codes).
Measured for this page on an earlier tree (before 38025ed), with a capsule written by veridor
capsule from a real finding against the vulnerable demo target, two counting
listeners (one answering 404 to everything, one 401), and the demo target
fixed and vulnerable:
| Capsule | Flags | Exit | Requests the listeners received |
|---|---|---|---|
| target: the vulnerable demo (control) | none | 1, CONFIRMED |
— |
| target: the fixed demo (control) | none | 0, NOT_REPRODUCED |
— |
target: a listener answering 401, as to stale tokens |
none | 2, INCONCLUSIVE, naming both identities and the 401 |
5 |
target: a listener answering 404 |
none | 2, INCONCLUSIVE: the marker control observed nothing |
5 |
a ${MY_CLOUD_SECRET} header added |
none | 2, naming the variable |
0 |
target http://192.0.2.10:8080 |
none | 2, "pass --base-url" |
0 |
| the same | --base-url at the 404 listener (control) |
2, INCONCLUSIVE |
5 |
check method changed to DELETE, loopback |
none | 2, "Writes never go to an address the capsule chose" |
0 |
| the same | --base-url at the listener, --read-only |
2, --read-only refuses DELETE |
0 |
| the same | --base-url at the listener (control) |
2, INCONCLUSIVE, after the write-mode banner |
4 |
check path /..%2fadmin/audit |
none | 2, must not contain a '.' or '..' segment |
0 |
check path /http://127.0.0.1:<listener>/steal on the demo target |
none | 2, must be a same-origin absolute path |
0 |
a Host: admin.internal check header |
none | 2, sets a Host header |
0 |
a params key in the check's request |
none | 2, has unsupported keys: params |
0 |
claim.check_id changed |
none | 2, naming both ids |
0 |
claim.evidence_grade: certain |
none | 2, not a grade Veridor writes |
0 |
scope beginning ESC[2J ESC[H |
none | 2, scope carries U+001B |
0 |
| loopback | --config, --report or --wait-for-target |
2, "does not apply --config" (and so on) |
0 |
Each listener was shown to count by a request of its own before the table was
run. Before these checks, an untrusted capsule could name any environment
variable and its own host, and verify would send that variable's value
there.
A suspected CONFIRMED can be a proxy talking. Anything that answers
every request with 200 (a proxy, a captive portal, a gateway serving its own
page) produces CONFIRMED when the evidence is suspected: the Veridor README
records a proxy answering 200 to everything drawing CONFIRMED, exit 1,
while the target received none of the four requests. The run cannot tell that
apart from a real vulnerability. The banner says when a proxy carries the
requests; before acting on a suspected CONFIRMED, look for the requests in
the target's own access log.
Scope selection¶
--include and --ignore match by path prefix — not as globs, 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, and {id} must be spelled the way the
specification spells it.
An --include list where every pattern misses is refused before a socket opens.
An empty selection produces an empty grid rather than a clean one.
Veridor never crawls or guesses routes. The inventory comes from the OpenAPI
file you point it at, and the concrete object behind each {id} comes from the
matrix.specimens you wrote.
The local viewer¶
Opens http://127.0.0.1:8787 over whatever is in .veridor/. It is a viewer
for artifacts already on your disk: it never writes, never issues a probe, and
serves nothing outside the output directory.
- Loopback only unless you acknowledge otherwise.
--hostanything else is refused:
veridor: --host 0.0.0.0 binds an address other than loopback, and the viewer has no authentication: every run document it serves -- target URLs, finding messages, config paths -- becomes readable by anything that can reach that address. Pass --i-understand-this-is-unauthenticated if that is what you want, or leave --host at 127.0.0.1 and use an SSH tunnel.
- No authentication, and there never has been. It has no login. If you want run history shared across a team, that is a different product than this one, and Veridor does not pretend to be it.
- Host header validation. A request whose
Hostis neither loopback nor the addressservewas told to bind is answered421 Misdirected Request. A missingHostis refused too. This is the DNS-rebinding defence: there are no CORS headers, so cross-origin JavaScript cannot read a response — but a page the user visits can point its own hostname at127.0.0.1, and then it is same-origin by the browser's rules and reads every run document. TheHostheader is the only thing that distinguishes that request from a real one. - Every byte is served from the package. The Content-Security-Policy is
default-src 'self'; frame-ancestors 'none', withReferrer-Policy: no-referrerandCache-Control: no-store. No webfont, no CDN, no tracker.
Outputs never overwrite inputs¶
Output paths are validated before anything runs: --json, --sarif,
--coverage, --badge and every --report path must be distinct and may
never overwrite the configuration, the baseline, the specification, or the
managed run history. No input may be a file the command itself rewrites under
--output-dir either: latest.json for run and verify, the mutation/
report for mutate. The CHANGELOG records run --baseline
.veridor/latest.json replacing its own baseline, after which the re-run
compared a live finding against itself and exited 0. capsule --output and
report --output may not overwrite the configuration or the run they read.
Paths are compared as files, not as strings: the same device and inode when
both exist, otherwise the same directory and names equal under that
directory's own case rule, so on a case-insensitive filesystem (the macOS
default) VERIDOR.JSON is refused where veridor.json would be.
Reporting a problem¶
Found something that would let an attacker reach a target, escape a safety gate,
or read a credential? Report it privately. The private reporting form opens
with the source repository; until then, contact the author through
github.com/Santoshkumarpuppala and do
not post details in public. The repository's SECURITY.md will also say what
Veridor does with your credentials and which of its dangerous capabilities are
deliberate.
If a report or a document overstates what was measured, that is a defect here too.
Related: what a finding does and does not mean,
exit codes, map.