Skip to content

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 map --config veridor.json --openapi openapi.json --dry-run
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/x reads 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 to host.
  • 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 of https://gateway/service-a/, /../service-b/admin used 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 %u002e or a segment with trailing spaces, are not judged.
  • A Host header, in any case and in any header map: checks, identities, marker probes, login steps, sequence steps, teardown, and as a session inject target. urllib sends it in place of the URL's host, and every check above reads the URL, so behind a gateway that routes by Host it delivered the request, credentials included, to another virtual host. The host belongs in base_url.
  • An unknown key in a request block. params, query, a bare Authorization or a misspelt Headers used to load and be dropped, so the request went out without them. A request takes method, path, headers, json and body; 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 302 to a login page is the denial in every session-based application, and following it would turn a denial into a 200.
  • 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.verbs and mutation.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 (exit 143), which is what a cancelled CI job, a Kubernetes pod termination and a Job's activeDeadlineSeconds send. SIGTERM takes the same path as SIGINT, so every finally runs; a second SIGTERM during that teardown is ignored rather than cutting it short. A run stopped before its probes finish writes no run document. SIGKILL cannot be caught, so a SIGKILL, 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, mutate and verify check that they can write their output directory and report destinations first, and exit 2 with Nothing has been sent if 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.

  • 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 exit 2 before 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 empty VERIDOR_OWNER_TOKEN exited 2 with 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), session inject values, every string or number anywhere beneath a credential-named key (password, accessToken, aws_secret_access_key, Okta's credentials.password.value, {name: password, value: ...} pairs), credential-named parameters in query strings, ; path parameters, form bodies and JSON text inside strings, and user:pass@ in any URL must all be ${...} references. The refusal lists the JSON paths (at most ten), never a value, and ends Nothing 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 (a pin or otp field, 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_TOKEN inside identity invoice_owner becomes VERIDOR_INVOICE_OWNER_OWNER_TOKEN), the renames are printed, and requires_environment lists what the verifier must set.
  • A session-backed identity is refused by map, not guessed at. map has 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 use run.

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:

  1. Only a short list of options applies. --base-url, --output-dir, --read-only, --json, --sarif and --no-prove-liveness. Any other run option typed next to a capsule (--config, --report, --wait-for-target, ...) is refused by name rather than silently ignored.
  2. 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 own requires_environment is not trusted. The refusal names the variable and where it appears.
  3. --base-url is 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, ::1 or exactly localhost; an unexpanded ${} counts as not loopback) is refused.
  4. --base-url is required for any write, loopback included: an untrusted DELETE aimed at 127.0.0.1 can reach a local admin service. With --read-only, any request outside GET, HEAD and OPTIONS is refused, a session login POST included.
  5. Requests must stay on the target: no path that becomes another URL, no . or .. segment in any spelling above, no Host header anywhere, no unknown request key (above).
  6. The capsule's words cannot draw your screen. A claim field (any string in it, keys and nested values included), the scope or the check's name carrying 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 whose scope began ESC[2J ESC[H used to clear the terminal and draw a verdict of its own over the real one.
  7. The claim must be about the check it carries. A claim.check_id that 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 out NOT_REPRODUCED, exit 0, whatever the target did.
  8. The claimed grade must be one Veridor writes: n/a, ungraded, suspected or confirmed. The grade is quoted in the verdict's reason, and a 3774-character one of spaces and a copied session wrapped into a NOT_REPRODUCED over a real CONFIRMED (recorded in the CHANGELOG).
  9. --json and --sarif may 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

veridor serve

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. --host anything 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 Host is neither loopback nor the address serve was told to bind is answered 421 Misdirected Request. A missing Host is 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 at 127.0.0.1, and then it is same-origin by the browser's rules and reads every run document. The Host header 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', with Referrer-Policy: no-referrer and Cache-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.