Multi-step checks (sequences)¶
A check is a fan-out: one request, sent as every identity it names. Some authorization bugs cannot be written that way, because proving them needs state that only exists between requests:
- an id the target returns when an object is created, which a later request has to use;
- an object provisioned as the victim during the run, rather than one seeded before it;
- a write whose effect only a later read can show. A cross-tenant
DELETEthat answers403and removes the row anyway looks correct to any check that reads only the attacker's response.
A sequence is the ordered form. It is a list of requests, each sent as
exactly one named identity, each declaring a status expectation, each optionally
naming values to carry forward to the requests after it. Sequences are declared
in the same configuration file as checks and run as part of
veridor run.
Sequences write to the target
A sequence that creates and deletes objects is for systems you own. Point it only at an 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. See write safety below.
A sequence you can run¶
No file in fixtures/ declares a sequence. The demo server,
scripts/demo_server.py, carries a stateful notes API for exactly this case,
at /api/v2/notes, which nothing up front provisions:
POST /api/v2/notescreates a note for the caller's tenant and answers201with{"id": …, "tenant": …}and aLocationheader;GET /api/v2/notes/<id>returns the note, including itsprovenancefield, to its own tenant and404to another tenant, in every mode;DELETE /api/v2/notes/<id>answers204to its own tenant and403to another tenant. With--vulnerable, that403is sent and the note is deleted anyway. With--vulnerable --noisy-write, the other tenant gets200and nothing is deleted.
Save this as veridor.notes.json. The tokens and markers are the demo
server's own fixed values.
{
"version": 2,
"target": {"base_url": "http://127.0.0.1:8765", "timeout_seconds": 3},
"identities": {
"owner": {
"headers": {"Authorization": "Bearer owner-token"},
"marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
},
"outsider": {
"headers": {"Authorization": "Bearer outsider-token"},
"marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
"marker_probe": {"method": "GET", "path": "/api/invoices/inv-200"}
}
},
"sequences": [
{
"id": "note-cross-tenant-delete",
"name": "Note cross-tenant delete",
"steps": [
{"role": "setup", "as": "owner", "name": "owner creates a note",
"request": {"method": "POST", "path": "/api/v2/notes",
"json": {"title": "fixture", "provenance": "${marker:owner}"}},
"expect": {"status": [201]},
"capture": {"note_id": "$.id"}},
{"role": "setup", "as": "owner", "name": "owner reads it back",
"request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
"expect": {"status": [200], "contains_marker": "owner"}},
{"role": "assert", "as": "outsider", "name": "outsider reads it",
"request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
"expect": {"status": [404], "excludes_marker": "owner"}},
{"role": "assert", "as": "outsider", "name": "outsider deletes it",
"request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
"expect": {"status": [403, 404]}},
{"role": "assert", "as": "owner", "name": "owner still has it",
"request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
"expect": {"status": [200], "contains_marker": "owner"}}
],
"teardown": [
{"as": "owner", "name": "owner removes the note",
"request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
"expect": {"status": [204, 404]}}
]
}
]
}
Read it top to bottom. The owner creates a note with its own marker planted in
the body, and Veridor keeps the returned id as note_id. The owner reads the
note back, which proves the marker is retrievable at that path. The outsider
then tries to read it and to delete it. Last, the owner reads it again: the
marker must still be there. Teardown deletes the note whatever happened before.
The outsider identity has a marker_probe because Veridor sends no
fabricated credential inside a sequence. Without some proof that the outsider's
token works, a 403 from the outsider is equally explained by a token that
expired. See liveness below.
The boundary holds¶
With the demo server running in its normal mode
(python3 scripts/demo_server.py):
veridor: write mode -- up to 3 mutating request(s) (DELETE, POST) will be issued against 127.0.0.1. Do not run this against production. Pass --read-only to refuse instead, before anything is sent.
veridor: liveness controls -- 2 sequence assert step(s) accept only denial statuses (GET /api/v2/notes/${note_id}, DELETE /api/v2/notes/${note_id}), and Veridor sends no credential it fabricated inside a sequence. …
Veridor run 20261110T043618Z-dacdb8: 0/0 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:8765
Regression diff: 0 new, 0 resolved, 0 unchanged
Evidence: 0 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
Sequences: 1 executed, 5/5 steps matched their declaration, 0 aborted, 0 teardown failure(s)
- note-cross-tenant-delete: completed (teardown: clean)
…
Exit code 0. The first line reads 0/0 probes because sequence steps are not
check probes; the Sequences: line is where they are counted.
The write crosses the boundary and the response hides it¶
Restart the demo server with --vulnerable and run the same command. The
outsider's DELETE still answers 403, the status the configuration allows, so
step 3 matches. The note is gone regardless, and step 4 shows it:
Veridor run 20261110T043550Z-8239ce: 0/0 probes matched their declaration, 1 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:8765
Regression diff: 1 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
Sequences: 1 executed, 4/5 steps matched their declaration, 0 aborted, 0 teardown failure(s)
- note-cross-tenant-delete: completed (teardown: clean)
…
- [vulnerable (confirmed)] note-cross-tenant-delete / owner: Identity 'owner' received HTTP 404 (declared: one of [200]). 'owner''s own marker was not observed, and step 3 issued DELETE as 'outsider' against the same path this step reads. 'owner''s marker was observed at step 1 of this sequence and is absent now.
Confirm your declaration that this identity's data must persist. If it holds, the write issued by the earlier step in this sequence took effect across the boundary -- enforce authorization on that write path. If the earlier identity is entitled to modify this object, correct the configuration instead.
Exit code 1. The finding is VERIDOR-EFFECT-001, graded confirmed because
step 1 of the same sequence observed the owner's marker at that path. Teardown
is clean because it accepts 404, which is what deleting an already-deleted
note returns.
The response claims a write that did not happen¶
With --vulnerable --noisy-write, the outsider's DELETE answers 200 and
deletes nothing. Step 3 fails its declaration, and step 4, the owner's read of
the same path, shows the note survived:
- [vulnerable] note-cross-tenant-delete / outsider: Identity 'outsider' received HTTP 200 (declared: one of [403, 404]). A later step in this sequence observed 'owner''s data still present after this request, so this response did not take effect on the object under test.
Exit code 1. The verdict is not softened: a target that answers 200 to an
unauthorized DELETE still fails the declaration. The finding's evidence
carries "write_effect": {"verified_by_step": 4, "state": "no_effect_observed"}
so triage can see what the later read established.
The schema, key by key¶
sequences is a top-level list, a sibling of checks. It requires
"version": 2; a configuration may declare checks, sequences, or both.
A configuration with no sequences key runs exactly as it did before: the same
run document, the same fingerprints, the same exit code.
Sequence¶
| Key | Required | What it does |
|---|---|---|
id |
yes | Non-empty, held to the same character set as a check id, and unique across checks and sequences |
name |
no | Used in finding titles; defaults to id |
steps |
yes | A non-empty list of steps, run in order |
teardown |
no | A list of teardown entries, run after the steps on every code path |
Step¶
| Key | Required | What it does |
|---|---|---|
role |
yes | setup or assert. No default |
as |
yes | The one identity this request is sent as. Must name a declared identity |
request |
yes | method, path, headers, json, body, as for a check's request |
expect |
yes | status (an integer or a non-empty list of integers from 100 to 599), and optionally one of contains_marker or excludes_marker |
capture |
no | An object mapping capture names to extractors |
name |
no | A label recorded with the step |
role has no default because both defaults are wrong. Defaulting to setup
would turn a forgotten role on the attacker's request into scaffolding: a green
run with the boundary never tested. Defaulting to assert would report a 500
on a provisioning POST as vulnerable.
contains_marker and excludes_marker follow the check rules: not both on one
step, the named identity must exist and declare a marker, and a step cannot
exclude the marker of the identity it runs as. A step does not need a
same-check control for excludes_marker; how that is licensed at run time is
under markers inside a sequence.
Teardown entry¶
| Key | Required | What it does |
|---|---|---|
as |
yes | The identity the cleanup request is sent as |
request |
yes | As for a step |
expect |
no | status only. Without it, any status the entry draws counts as matched |
name |
no | A label recorded with the entry |
A teardown entry cannot declare role, capture, contains_marker or
excludes_marker, and cannot plant a marker. It may use any value an earlier
step captures.
An unrecognised key at the sequence, step or teardown-entry level loads as an
annotation and is ignored, as elsewhere in the file. A key that is a
near miss of a real one,
or whose name contains marker (my_marker_note), is refused with exit 2.
Here a step declares captur beside its capture:
veridor: sequence 'note-cross-tenant-delete' step 0 has unsupported keys: captur (did you mean one of as, capture, expect, name, request, role?) Rename it, or start it with '_' if it is an annotation.
A key starting with _ is never judged.
Renaming a step's only capture to captur stops at a different error first.
With no step capturing note_id, ${note_id} in the later steps is read as an
environment variable:
Caps¶
Each is a refusal at load time, with no override flag:
| Cap | Limit |
|---|---|
| sequences per configuration | 64 |
| steps per sequence | 12 |
| teardown entries per sequence | 6 |
| captures per step | 8 |
${…} interpolation sites per step or teardown entry |
16 |
| captured value length | 512 characters |
Capturing a value¶
A step's capture reads values out of that step's response and keeps them,
under the names you give, for the steps and teardown entries after it.
There are two extractor forms and no others.
| Form | Reads | Example |
|---|---|---|
$ followed by .key and [index] segments |
A value from a JSON response body | $.id, $.data.items[0].id |
header:Name |
A response header, matched case-insensitively | header:Location |
A JSON extractor has no wildcards, filters or recursive descent. Keys are
letters, digits, _ and -, starting with a letter or _; indexes are
non-negative integers without leading zeros. An extractor outside this grammar
is refused at load time:
veridor: sequence 'note-cross-tenant-delete' step 0 capture 'note_id' is not a valid extractor: '$..id'
There is deliberately no regex source and no transform. A regex over a response body is an unreviewable mini-language inside a configuration, it lets a body the target controls choose which bytes enter a later authenticated request, and a backtracking regex over target-controlled input can stall the runner.
What a captured value can be¶
A capture resolves only when all of these hold. Anything else is a capture miss, and the reason is recorded:
| Reason | Means |
|---|---|
not_json |
A JSON extractor, and the response Content-Type was not application/json or …+json |
body_too_large |
The body was truncated, so it is not a whole JSON document |
malformed_json |
The body did not parse |
path_not_found |
A key or index on the path was missing |
unsupported_type |
The value was a float, a boolean, null, an object or a list. Only strings and integers are captured |
header_absent |
The header was not in the response |
ambiguous_header |
The header arrived more than once with different values. Veridor does not guess which one the target meant |
unsafe_value |
Empty, longer than 512 characters, non-ASCII, or containing a space or a control character. The value goes onto the wire in a path or a header, where a \r is a second request |
step_not_executed |
An earlier capture on the same step missed, or the step was skipped |
A miss stops the sequence. The steps after it are skipped, never sent with missing values.
Capture names¶
Capture names are identifiers: [A-Za-z_][A-Za-z0-9_]*. Within one sequence
they are single-assignment; two steps capturing the same name is a load error.
run_id is reserved. A step can only use a value an earlier step captures:
veridor: sequence 'note-cross-tenant-delete' step 1 references '${note_id}', which no earlier step captures; it is captured by step 4, which runs later
A capture name shares its ${NAME} spelling with
environment substitution, so a
capture name that is also set as an environment variable is refused rather than
silently resolved from the environment:
veridor: capture name 'note_id' collides with an environment variable of the same name; rename the capture
Step captures are a different mechanism from
session captures, which read login responses
and are written ${capture.<name>}.
Where ${…} may appear¶
| Token | Resolves to | Allowed in |
|---|---|---|
${name} |
A value an earlier step captured | the path, header values, and string values in json or body |
${run_id} |
This run's id | the path and the body; not a header |
${marker:identity} |
That identity's declared marker | a request body only, on a setup step, for the step's own identity |
A token is never allowed in a header name or in a JSON object key. A ${ that
does not form one of these tokens is a load error rather than literal text on
the wire.
Substitution is textual, with one typing rule: a JSON string that is exactly one
capture token keeps the type the value was captured with. So
{"note_id": "${note_id}"} sends an integer when the target returned an
integer.
A path may begin with a capture, for example "path": "${note_url}" with a
capture of header:Location. The path rules for checks
(request) still apply, and the resolved path
is checked again after substitution. A step whose resolved path leaves the base
URL's origin or path, carries a fragment, or contains a . or .. segment is
not sent, and the sequence stops with path_invalid.
Markers inside a sequence¶
${marker:owner} is the only way to plant a marker from a sequence. A marker
literal pasted anywhere into a sequence's requests is a load error, as it is in
a check: a marker Veridor sent itself and then found cannot prove a leak.
A step that plants a marker:
- must be a
setupstep and must plant the marker of the identity it runs as; - cannot declare
contains_markerorexcludes_marker. Its response is never scanned, because a target that echoes the created object back in its201would otherwise "find" the marker it was just sent.
The same rule holds for captured values at run time. If a captured value
carries a configured marker, for example because the target echoed the planted
body back into a field that was captured, a later step that would send that
capture is not issued, and the sequence reports inconclusive with
marker_reflection_blocked.
An excludes_marker on an assert step needs something that shows the marker is
live. Veridor takes the first that applies:
- An earlier step in this sequence, sent as that identity, observed its
own marker with
contains_marker, in a response not served from a cache (recorded assequence_prior_step). In the example, step 1 licenses step 2. - The identity's
marker_probe(recorded asidentity_probe), unless this sequence plants that identity's marker. A probe read an object that existed before the run, not the one the sequence just created. - Nothing (recorded as
none). A matched status on such a step, or on one whosemarker_probedid not verify, is reported asVERIDOR-ORACLE-001, inconclusive, rather than as a pass.
How a sequence is graded¶
Setup steps are preconditions¶
A setup step establishes state. It never produces a vulnerability. When a setup
step does not match its declaration — a status outside status, a declared
contains_marker not observed, or a capture miss — the sequence stops. The
remaining steps are skipped, and one VERIDOR-SEQ-001 finding is reported,
inconclusive. A sequence that cannot establish its own preconditions is never
reported as safe and never as vulnerable.
- [inconclusive] note-cross-tenant-delete / owner: Sequence 'note-cross-tenant-delete' could not be completed. Step 0 (setup, as 'owner', POST /api/v2/notes) did not yield capture 'note_id' (path_not_found), so the state the later steps depend on was never established. Steps 1 (setup), 2 (assert), 3 (assert), 4 (assert) were not executed. No conclusion about authorization was reached: this is neither a pass nor a failure of the declared expectations.
That run, with the capture changed to $.note.id, exits 3: not one assert
step was answered, and a run that measured nothing does not exit 0.
Assert steps are the measurements¶
An assert step is graded like one identity's probe in a check, using the same verdicts and evidence grades:
| Rule | Verdict | When |
|---|---|---|
VERIDOR-AUTHZ-001 |
vulnerable or unexpected_denial |
The status was outside status. confirmed when the response also carried the excludes_marker identity's marker; otherwise suspected or ungraded |
VERIDOR-LEAK-001 |
vulnerable, confirmed |
The status matched, and the response carried the excludes_marker identity's marker |
VERIDOR-EFFECT-001 |
vulnerable |
The step reads its own identity's data with contains_marker, the marker is absent, and an earlier step that reached the target sent a method other than GET, HEAD or OPTIONS, as a different identity, to the same resolved path. confirmed when an earlier step of this sequence observed that identity's marker; suspected when none did, because absent now may mean never there |
VERIDOR-ORACLE-001 |
inconclusive |
The status matched but a declared marker could not be verified: contains_marker not observed, or excludes_marker with no live control or an unreadable response |
VERIDOR-EXEC-001 |
inconclusive |
The request failed or was not sent, or drew a 429, a 5xx or a 407 outside its declared statuses with no marker observed. None of those is an answer from the authorization layer |
VERIDOR-SESSION-001 |
inconclusive |
The step's identity uses a session and drew 401 or 407, which is also what an ended session looks like |
VERIDOR-CACHE-001 |
inconclusive |
The response announced it came from a cache and the step declares a marker expectation |
VERIDOR-EFFECT-001 is the reason sequences exist. The earlier write's status
is deliberately not part of the rule: a 403 that deletes the row anyway is
exactly the defect it catches.
Unlike a refusal elsewhere, the owner's 404 in an effect finding is graded
vulnerable, not unexpected_denial: here the refusal is the damage.
When an assert step sends a method other than GET, HEAD or OPTIONS and
draws a VERIDOR-AUTHZ-001, Veridor looks for a later assert step on the same
resolved path, sent as a different identity, that declares contains_marker for
its own identity. If one exists, the finding's evidence gains a
write_effect object with verified_by_step and a state:
state |
Means |
|---|---|
effect_observed |
The later read produced a VERIDOR-EFFECT-001 |
no_effect_observed |
The later read found the marker still present. The message says the write did not take effect; the verdict is unchanged |
not_verified |
The later read was a cached response, or did not find the marker present |
An assert step that is answered with 429, 5xx or 407, a step whose request
fails, and a capture miss on an assert step also stop the sequence; later steps
are skipped.
Why a sequence stopped¶
The run document records abort_reason from a closed set:
abort_reason |
Means |
|---|---|
setup_failed |
A setup step's status or contains_marker did not match |
capture_missed |
A capture did not resolve |
execution_error |
A request failed, or a session was not established, so the step was not sent |
path_invalid |
A path became invalid only after substitution, so the step was not sent |
marker_reflection_blocked |
A captured value carried a configured marker |
marker_check_incomplete |
A captured value could not be checked for a marker |
step_inconclusive |
An assert step was answered with 429, 5xx or 407 |
session_not_live |
A setup step for a session identity drew 401 or 407 |
response_from_cache |
A setup step with contains_marker was served from a cache |
Teardown¶
Every teardown entry is attempted on every code path Veridor can see: a normal
finish, a setup step that failed, a step that errored, an unexpected exception,
Ctrl-C (exit 130) and SIGTERM (exit 143). Teardown runs per sequence, right
after that sequence's steps, so an interrupt only owes cleanup to the sequence
in flight. SIGKILL cannot be caught, so a SIGKILL or a runner eviction can
leave fixtures behind.
Four rules hold:
- Teardown never changes a verdict. Its results create no findings, enter no probe count and contribute to no fingerprint. Grading uses records that were complete before cleanup started.
- Teardown never masks a finding. A finding always wins the exit code.
- Every entry is attempted. One failure does not stop the entries after it.
- Teardown is loud. Every failure and skip prints to stderr. Under GitHub
Actions (
GITHUB_ACTIONSset) it is also emitted as a::warningannotation.
An entry that references a capture that never resolved is skipped, never sent
with the literal ${…} text. An entry whose identity's session was not
established is skipped too; teardown does not log in again. Teardown has its
own per-request timeout, target.teardown_timeout_seconds, which defaults to
timeout_seconds (see target).
The outcome is recorded per sequence as teardown_state:
teardown_state |
Means |
|---|---|
clean |
Every entry was sent and drew a status its expect allows |
failed |
An entry errored or drew a status outside its expect |
incomplete |
Nothing failed, but at least one entry was skipped. A skipped entry still counts toward teardown failure(s) on the Sequences: line and in summary.teardown_failures |
not_required |
The sequence declares no teardown |
aborted |
The run was interrupted |
A teardown that declares "status": [200] against the demo, which answers
204:
veridor: teardown entry 0 of sequence 'note-cross-tenant-delete' (DELETE http://127.0.0.1:8765/api/v2/notes/${note_id}, as 'owner') did not complete cleanly: executed -- HTTP 204. The target may hold a fixture this run created.
…
Sequences: 1 executed, 5/5 steps matched their declaration, 0 aborted, 1 teardown failure(s)
- note-cross-tenant-delete: completed (teardown: failed)
That run exits 0: an unclean cleanup is reported everywhere but does not fail
the security gate on its own. Add --require-clean-teardown to exit 3
instead:
veridor: teardown did not complete cleanly for note-cross-tenant-delete; the target may hold fixtures this run created
Declare expect statuses that cover an object already gone. In the example,
[204, 404] keeps teardown clean when the vulnerable target deleted the note
before teardown did.
A capture miss can orphan what the setup step created
A step's captures are read after its request was answered, so a setup
POST that created an object and then missed its capture has already
written. Every teardown entry that needs that capture is then skipped
(teardown: incomplete). In the capture-miss run above, the demo server
had created the note before the miss, and no request removed it. Recreate
the environment, or clean it up by other means.
Write safety¶
A run that will send a POST, PUT, PATCH or DELETE prints one banner on
stderr before its first request:
veridor: write mode -- up to 3 mutating request(s) (DELETE, POST) will be issued against 127.0.0.1. Do not run this against production. Pass --read-only to refuse instead, before anything is sent.
The run document then records a mutation block:
verbs lists the mutating methods the configuration declares, not the ones
that were sent. requests_issued counts the mutating step, teardown, check and
marker-probe requests that were attempted rather than skipped; a request that
errored, including a refused connection, still counts. The block is present
whenever the configuration declares a mutating request, even if nothing reached
the target, and absent only for a configuration that declares none. In the
capture-miss run above, only the POST was sent, and the block reads
{"verbs": ["DELETE", "POST"], "requests_issued": 1}.
--read-only refuses the whole configuration, before any request, if any
declaration uses a method outside GET, HEAD and OPTIONS:
veridor: --read-only refuses POST in sequence 'note-cross-tenant-delete' step 0, and 2 other mutating declaration(s). All 3 are listed below (verbs: DELETE, POST); nothing has been sent, and together they declare 3 request(s) -- more under `mutate`, which re-issues each one per probe:
POST in sequence 'note-cross-tenant-delete' step 0
DELETE in sequence 'note-cross-tenant-delete' step 3
DELETE in sequence 'note-cross-tenant-delete' teardown entry 0
Exit code 2. If losing an object in the target would matter to anyone, use
--read-only.
Two limits, stated plainly:
- If the attacker's own request writes and succeeds — that being the vulnerability — the resulting change belongs to the attack, not to the fixture. Veridor does not try to undo it.
- Sequences run strictly one after another, and so do their steps. There is no interleaved fixture state.
More on running against a target you can write to is in safety posture.
Liveness: a denial inside a sequence¶
For a GET, HEAD or OPTIONS check whose expectation accepts only denial
statuses, run sends a
fabricated credential to tell a refusal apart from a dead token
(the liveness controls). It never does
so inside a sequence, and says so before the first request.
So an assert step that accepts only denial statuses, sent as an identity with a
credential header, counts as unproven when some identity was served in the
same sequence and nothing else in the run proves this identity's credential
live. Remove the marker_probe from the example's outsider and run it again:
Unproven denials: 2 expectation(s) held against a denial that a credential Veridor fabricated received too, or where no fabricated credential was sent to compare with, so they did not establish who was denied.
- sequence note-cross-tenant-delete step 2 / outsider: 'outsider' received HTTP 404 on step 2 (GET) of sequence 'note-cross-tenant-delete'. Veridor does not send a credential it fabricated inside a sequence, so nothing here shows the target recognised 'outsider''s credential. The step accepts only denial statuses, so it is satisfied by a boundary that refuses this identity AND by a credential that expired or was mistyped. This step does not distinguish them.
Give 'outsider' a marker_probe naming something it SHOULD be served, or declare a check or a step where it is served and an identity with no credential is refused on the same request (a GET check also gets a fabricated credential that must be refused). Either proves the credential live and this disclosure disappears.
…
Steps 2 and 3 are both listed: the outsider's read and its delete.
The run reports this and exits 0. With --require-complete the same run exits
3 (reason liveness_unproven). The fix is a marker_probe on that identity
naming something it should be served, as in the example, or a check where it is
served and an identity with no credential is refused on the same request.
What is reported¶
On the console¶
A run that executes sequences prints a Sequences: line with the number of
sequences executed, steps matched out of steps recorded, sequences aborted and
teardown failures, then one line per sequence with its outcome
(completed or aborted), its teardown_state, and, when it stopped, the
step and abort_reason. Findings from steps are listed with the other findings,
as <sequence id> / <identity>.
In the run document¶
The run document gains a sequences list, one record per sequence:
| Field | Holds |
|---|---|
sequence_id, name |
From the configuration |
outcome |
completed or aborted |
aborted_at_step, abort_reason |
null unless the sequence stopped |
steps |
Per step: index, as, role, name, request (method and URL), response, error, state (matched, mismatched, inconclusive, errored or skipped), captures (name, extractor, whether it resolved and the miss reason), marker_planted, marker_scanned |
teardown |
Per entry: index, as, request, status, matched, state, error |
teardown_state |
As above |
summary gains sequences, sequence_steps, sequence_steps_matched,
sequences_aborted and teardown_failures. Each step finding carries
step_index and an evidence.sequence object with the sequence id, step
index, role, the sequence's outcome and the indexes of skipped steps.
Captured values are never written down. The URL recorded for a step or a
teardown entry is the configuration template with its ${…} tokens intact
(http://127.0.0.1:8765/api/v2/notes/${note_id}), not the resolved URL. The
captured values live in memory for one sequence and are dropped when it ends.
Error text that might contain one has each captured value of four characters or
more replaced with ${name}.
Fingerprints come from the template too. A step finding's id is computed from the sequence id, the identity, the method, the path as written, the body as written, the declared statuses and the step index. The ids are stable across runs even though the captured ids differ every run, so a baseline keeps working.
Exit codes for a run with sequences¶
| Code | When |
|---|---|
0 |
No finding the gate fails on. An unclean teardown or an unproven denial alone stays 0 unless its flag is set. With --fail-on none, a run where no assert step was answered, or where a session identity failed to log in, also exits 0 |
1 |
A step produced a finding the gate fails on |
2 |
The configuration was refused at load time, or --read-only refused it. Nothing was sent |
3 |
No finding, but not one assert step was answered; or a session identity failed to log in for a reason that will repeat (a refused credential, not a 429 or 5xx), with no flag needed; or a teardown was unclean with --require-clean-teardown; or, with --require-complete, a denial was unproven or a probe or step came back inconclusive |
130, 143 |
Interrupted by SIGINT or SIGTERM. Teardown for the sequence in flight runs first. A run stopped before its probes finish writes no run document |
The full table is in exit codes.
What a sequence is not¶
There are no conditionals, no loops, no arithmetic, no expressions, no transforms and no retries, and none are planned. The only state a sequence carries is a flat, single-assignment map of names to strings and integers, which never touches disk. A configuration nobody can review is not a security tool.
veridor mutate mutates checks only. The sequences of a
version-2 configuration are counted as unmutated_sequences and named in its
report header, so its score never silently covers them.