The GitHub Action¶
You want the build to fail when an authorization boundary changes. The Action
in the Veridor repository (action.yml) is one step per verb: it runs Veridor
from its own checkout, publishes what the run measured as step outputs and a
job summary, and then fails the job on Veridor's own exit code.
Nothing is released yet
There is no release tag and no Marketplace listing, and the repository,
Veridor-authz/veridor, is private, so nothing outside it can point
uses: at it. Veridor-authz/veridor@<full-commit-sha> in the examples
below is a placeholder that resolves to nothing today. After a first
release, replace it with that release's full 40-character commit SHA and
keep the version as a comment.
There is no moving v0 or v1 tag, and the release workflow
(.github/workflows/release.yml) says so in its header: during 0.x a minor
release may change behaviour, and a floating reference would change what
runs in your pipeline, with your credentials in its environment, without a
commit of yours.
One step is one verb. mode: run gates on the expectations you declared.
mode: drift declares nothing and diffs this run's access matrix against a
baseline you committed. If you want both, use two steps. They exit non-zero for
different reasons, and a shared exit code would hide which one failed.
The recommended workflow¶
permissions:
contents: read
security-events: write # for the SARIF upload step only
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: Veridor-authz/veridor@<full-commit-sha> # vX.Y.Z, after the first release
id: veridor
with:
config: veridor.json
# Omit on a first run: nothing produces baseline.json yet, and a missing
# one is a hard exit 2. Add it once you have a run to compare against,
# and commit it.
# baseline: baseline.json
# Not `new`. `new` is a set difference against a file inside the diff it
# is gating, so adding a finding id to baseline.json turns a live
# confirmed leak into a green build.
fail-on: new+confirmed
# Already the default in this Action; stated because it is the difference
# between "verified" and "unverified".
require-complete: true
openapi: openapi.json
- if: ${{ always() && steps.veridor.outputs.sarif != '' }}
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
sarif_file: ${{ steps.veridor.outputs.sarif }}
category: veridor
The three third-party SHAs above are the ones the Veridor repository pins. Each
was resolved from its tag with gh api repos/<owner>/<repo>/git/ref/tags/<tag>
on 2026-09-24 (the codeql-action tag is annotated and was dereferenced to its
commit) and matched.
fail-on: new+confirmed is the default and what this page recommends. It is
the union of two gates: a finding absent from the baseline, or a finding
whose response actually carried another identity's marker, however long it has
been in the baseline. See why novelty alone is not a gate.
require-complete defaults to true here and is opt-in on the CLI. The CLI
is used interactively, where a partial answer is often what you asked for;
selecting this Action is itself the request for a gate. With it on, a probe the
target could not answer exits 3 rather than passing as a boundary that held.
What the step does¶
action.yml is a composite action with two steps.
Run Veridor (id: veridor):
- Refuses any input containing a line break (it could forge extra lines in
GITHUB_OUTPUT). - Parses the boolean inputs strictly (below).
- Finds Python: the first of
python3,pythononPATHwhose version it parses as 3.11 or newer. If neither qualifies, it refuses with exit2and a message naming what it found and telling you to addactions/setup-python. - Runs Veridor from the Action's own checkout, as
PYTHONPATH="$GITHUB_ACTION_PATH/src" python -P -m veridor .... Nothing is installed and no package index is contacted.-Pkeeps the working directory, which is your repository, offsys.path, so averidor/directory in your repository cannot stand in for the package. It checks the location it loaded and prints the version and path before it starts. The only network traffic is what Veridor sends to your target, and it goes through a proxy only if the job setshttp_proxyorhttps_proxyfor a target that is not loopback, which Veridor then says in the step's log (proxies). - Writes the outputs, appends the Markdown report to the job summary, and
exits
0deliberately, so that a later step can read the outputs.
Fail the job on Veridor's exit code then exits with the recorded code, or
with 1 if the code is missing or not a number. A missing code means the first
step never reached an opinion, and that is not a pass.
The first step writes exit-code on every path out of it, refusals included.
If the Action's own script fails, it says so in an ::error annotation ("a
defect in the action, not a finding about your target") and the step fails
with that script's status.
Each Veridor exit code gets its own error annotation title: Veridor gate
failed (1), Veridor refused (2), Veridor gate incomplete (3), Veridor
internal error (70), Veridor interrupted (130), Veridor terminated (143).
In drift mode the exit-3 annotation names drift's causes instead of run
mode's: no cell measured in both this run and the baseline, a matrix that
measured nothing, or, with require-complete, a probed cell that went
unmeasured. See exit codes for what each code means.
Strict booleans¶
read-only, require-complete, require-clean-teardown, allow-no-matches
and summary accept true or false in any letter case, and nothing else.
yes, 1, on and an empty value are refused with exit 2, naming the input.
This used to be a fail-open: read-only: yes compared unequal to true and
silently turned the refusal of mutating requests off.
The job summary¶
With summary: true, the default, the step appends the Markdown report to
$GITHUB_STEP_SUMMARY: the gate verdict, the counts, the findings, the gate
reasons and the scope sentences. What earlier steps wrote there is kept. When
Veridor exits before it has written a run document, as it does on a refusal,
the summary says so ("exited N before it wrote a run document ... nothing was
concluded") instead of showing a result. The report format is on
reports.
Uploading SARIF¶
The upload is your step, not the Action's. It needs security-events: write on
the job, and the Veridor README adds actions: read for a private repository.
always() is what makes it run after the gate fails, which is exactly when
there is something to upload. The sarif output is what keeps it from
uploading the wrong file: it is set only when Veridor wrote the SARIF during
that step, so after a refusal it is empty and the upload is skipped. The
example used to test hashFiles('veridor.sarif'), which is true for a file an
earlier step left at that path, and uploaded that file as this run's.
The SARIF carries runs[].automationDetails.id = veridor/<run_id> (measured
on a local run: veridor/20260924T073701Z-90e165). By GitHub's documented rule
the category is everything before the last /, so every run files under
veridor, and the example passes the same category: veridor. How code
scanning groups, ranks and closes these alerts has not been observed: no
upload of Veridor SARIF to code scanning has been observed. The project's own
CI uploads only on a manual run (workflow_dispatch), and the repository,
private on a free organisation, has no code scanning to upload to. The file
itself is described on SARIF and code scanning.
Drift mode¶
- uses: Veridor-authz/veridor@<full-commit-sha> # vX.Y.Z, after the first release
with:
mode: drift
config: veridor.json
openapi: openapi.json
matrix-baseline: veridor/baseline-matrix.json
fail-on-drift: opened
matrix: current-matrix.json
acknowledge-target: I am authorized to test staging.example.com
matrix writes this run's matrix, so a failing build hands you the file to
review and commit. Accepting a change is then a reviewable diff: copy the new
matrix over matrix-baseline and commit it with the code that caused it.
matrix-baselinemust be a file that exists. A missing one fails the step rather than passing as a gate with nothing to compare, and so does one that exists but shares no measured cell with this run: Veridor exits3. A baseline recorded while the target was down, or while every request was redirected, is the usual cause (see drift). A directory is refused: a directory resolves to the newest matrix in it, this step writes one more, and the re-run of a red build would compare the regression against itself.openapiis required in drift mode. The matrix is projected from that inventory, and Veridor never discovers routes.fail-on-driftdefaults toopenedhere and tononeon theveridor mapCLI, for the same reasonrequire-completediffers.- The drift report is rendered to the Markdown output and the job summary. It is Markdown only: drift is an observation, never a finding, so no SARIF or JUnit is written in this mode.
Inputs¶
Every input is a string. Mode-specific inputs default to empty, so the step can tell "not given" from "given".
| Input | Default | Mode | What it does |
|---|---|---|---|
mode |
run |
both | run or drift; one verb per step. |
config |
required | both | Path to the Veridor JSON configuration. |
baseline |
empty | run | A prior run JSON to compare against. |
output-dir |
.veridor |
both | Directory for run history. |
sarif |
empty | run | SARIF path; empty means veridor.sarif. |
junit |
empty | run | JUnit XML path; empty means a file in a fresh directory under RUNNER_TEMP. |
markdown |
empty | both | Markdown report path; empty means a file under RUNNER_TEMP. |
summary |
true |
both | Append the Markdown report to the job summary. |
fail-on |
empty | run | Gate policy; empty means new+confirmed. Also new, any, confirmed, none. |
json |
empty | run | Run JSON path; empty means a file under RUNNER_TEMP. |
base-url |
empty | both | Override target.base_url. |
wait-for-target |
0 |
both | Seconds to wait, before the first request, for the target's host and port to accept a TCP connection (--wait-for-target), for a service started in the same job. 0 does not wait; anything but a whole number is refused with exit 2. It never changes a verdict. |
openapi |
empty | both | OpenAPI 3 or Swagger 2 JSON. Enables boundary coverage, computed offline. Required in drift mode. |
coverage |
empty | both | Coverage report JSON path. Needs openapi. |
badge |
empty | both | shields.io badge endpoint JSON path. Needs openapi. |
base-path |
empty | both | Specification path prefix; omit to auto-detect. Needs openapi. |
allow-no-matches |
false |
both | Report coverage as not measured instead of failing when no check path matches the specification. Needs openapi. |
matrix-baseline |
empty | drift | Required. The committed matrix file to diff against. |
fail-on-drift |
empty | drift | opened (what empty means), any or none. |
drift |
empty | drift | Drift report JSON path; empty means a file under RUNNER_TEMP. |
matrix |
empty | drift | Also write this run's matrix here. |
acknowledge-target |
empty | drift | I am authorized to test HOST, required by map for a target that is neither loopback nor private. |
max-requests |
empty | drift | Refuse a matrix plan larger than this (Veridor's default is 500). |
rate |
empty | drift | Requests per second, sequential (Veridor's default is 2). |
read-only |
false |
run | Refuse a configuration that can send anything but GET, HEAD or OPTIONS. |
require-complete |
true |
both | Exit 3 when any probe went unanswered. |
require-clean-teardown |
false |
run | Exit 3 when a sequence teardown was unclean. |
The Veridor repository's tests/test_action.py fails if its README's copy of
this table and action.yml disagree on a name, a default or a mode.
An input belonging to the other mode is refused, never ignored¶
Silently dropping sarif from a drift step would leave the workflow waiting on
a file that was never going to arrive, so the step refuses with exit 2 and an
::error:: annotation.
| Supplied on | Input | Result |
|---|---|---|
mode: run |
matrix-baseline, drift, matrix, acknowledge-target, max-requests, rate, fail-on-drift |
Refused at any non-empty value. |
mode: drift |
baseline, json, sarif, junit, fail-on |
Refused at any non-empty value. |
mode: drift |
read-only, require-clean-teardown |
Refused when true. map sends no mutating verb unless one is named for it, and this Action names none; map runs no sequence, so there is no teardown to require. |
| either | coverage, badge, base-path, or allow-no-matches: true, without openapi |
Refused. |
The one thing the step cannot tell apart is an input you left out and one you
set to an empty string: sarif: '' on a drift step is accepted, because it asks
for nothing.
Outputs¶
| Output | Mode | What it holds |
|---|---|---|
exit-code |
both | Veridor's exit code as a string. Written on every path out of the step. |
version |
both | The Veridor version the Action ran, read from its own checkout. |
run-json |
run | Path of the run document; empty unless Veridor wrote it during this step. |
sarif |
run | Path of the SARIF file; empty unless Veridor wrote it during this step. Condition an upload on this. |
junit |
run | Path of the JUnit XML report; empty unless Veridor wrote it during this step. |
markdown |
both | Path of the Markdown report; empty unless Veridor wrote it during this step. |
drift-json |
drift | Path of the drift report; empty unless Veridor wrote it during this step. |
coverage-json |
both | Path of the coverage document, when openapi is set; empty unless Veridor wrote it during this step. |
run-id |
run | The run identifier. |
gate-exit-code |
run | The exit code the gate decided, from the run document's gate object. Equal to exit-code unless writing a report failed afterwards. |
gate-reasons |
run | The gate's reasons as one line of JSON; [] when it passed. See reports. |
findings |
run | Findings of every verdict, inconclusive probes included. |
findings-new |
run | Findings absent from the baseline. |
findings-vulnerable |
run | Findings whose verdict is vulnerable. |
findings-inconclusive |
run | Probes that could not be graded. |
coverage-percent |
both | Boundary coverage as a percentage, to one decimal place. |
coverage-status |
both | The coverage document's own status string, passed through verbatim. Do not branch on a value you have not seen this tool emit. |
drift-changes |
drift | Differences from the baseline. |
drift-significant |
drift | Differences that mean more access than the baseline. |
drift-blind-cells |
drift | Cells the baseline never measured. |
A path output (run-json, sarif, junit, markdown, drift-json,
coverage-json) is set only when Veridor wrote that file during this step,
and is empty otherwise, after a refusal for one. It used to be set either way,
so after a refusal sarif named whatever an earlier step had left at
veridor.sarif (the CHANGELOG records the test harness reproducing it). Counts
and the gate are read only from documents Veridor wrote during this step too.
Before Veridor starts, the step records which file sits at each path (device
and inode), and afterwards takes a path only if a different file is there;
Veridor writes every document by renaming a new file over the path. It used to
compare modification times, which let a stale document from the same second,
or from a clock ahead, pass as this step's. On exit 2 no run document
exists, so no findings-* or gate-* output is emitted at all.
Quote findings-vulnerable in a summary comment, not findings: the latter
counts inconclusive probes, and a probe that went unanswered is not a finding
about your target. A gate that passes with a high findings-inconclusive or
drift-blind-cells has not established what its silence suggests.
Outputs and the continue-on-error trap¶
- uses: Veridor-authz/veridor@<full-commit-sha> # vX.Y.Z, after the first release
id: authz
continue-on-error: true # you MUST re-fail below, or the gate is off
with:
config: veridor.json
openapi: openapi.json
- name: Comment on the pull request # needs pull-requests: write
if: ${{ always() && github.event_name == 'pull_request' && steps.authz.outputs.findings-vulnerable != '' && steps.authz.outputs.findings-vulnerable != '0' }}
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
VULNERABLE: ${{ steps.authz.outputs.findings-vulnerable }}
INCONCLUSIVE: ${{ steps.authz.outputs.findings-inconclusive }}
run: |
gh pr comment "$PR" --body "Veridor: $VULNERABLE vulnerable, $INCONCLUSIVE inconclusive. The job summary has the rest."
- name: Re-fail on the gate's exit code
if: ${{ always() }} # without this the build is green on a finding
env:
VERIDOR_EXIT_CODE: ${{ steps.authz.outputs.exit-code }}
run: exit "${VERIDOR_EXIT_CODE:-1}"
continue-on-error: true turns the gate off
It is required to read outputs after a failing run, and it makes this Action advisory. Without the final step, a run that finds a live cross-tenant read leaves the build green.
The :-1 is not decoration: a missing exit-code means the step never got far
enough to have an opinion. The != '' guard on the comment matters for the
same reason: on exit 2 no findings-* output is emitted, and a bare
!= '0' compares against an empty string and fires. Values reach the run:
script through env:, never through an expression inside it.
Without continue-on-error you need none of this; the Action's own last step
already carries Veridor's verdict.
Credentials¶
Keep tokens in Actions secrets and reference them as ${VERIDOR_...} in the
configuration. Veridor expands ${NAME} at load time, and a variable that is
unset or set but empty stops the run with exit 2 before anything is sent.
A pull request from a fork gets no secrets; that is an exit 2, not a probe
without the credential. See configuration.
What was measured, and what was not¶
| What | Status |
|---|---|
action.yml executed step by step by tests/action_harness.py under the runner's bash flags, against the demo server: vulnerable → exit 1, fixed → 0, unreachable → 3, a bad boolean refused before Veridor runs, Python older than 3.11 refused by name, a veridor/ decoy in the workspace not loaded (with a control where, without -P, it is), SARIF carrying the veridor/ category, path outputs empty after a refusal while a stale file sits at the path |
Executed. python3.12 -m unittest tests.test_action on the tree at commit 38025ed: 72 tests, OK (macOS, 2026-11-09). The Veridor README records the same harness on macOS bash 3.2 and Linux bash 5.2. |
uses: ./ on GitHub-hosted runners (action-e2e job, ubuntu-latest and macos-latest): the vulnerable demo must fail its step with exit-code 1, findings-vulnerable 1 and gate reason findings, and write SARIF and JUnit; the fixed demo must pass with 0. The vulnerable step runs under continue-on-error, and a later step reads its outputs |
Executed in the project's own CI, workflow run 42508338432 (push to main at 38025ed): both jobs succeeded. The repository is private, so the run is not public. Not run from another repository at a pinned commit. |
| The code-scanning category, alert ranking | Not observed. |
| Windows runners | Untested. |
Related: exit codes, reports, SARIF and code scanning, drift, safety posture.