Skip to content

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.

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):

  1. Refuses any input containing a line break (it could forge extra lines in GITHUB_OUTPUT).
  2. Parses the boolean inputs strictly (below).
  3. Finds Python: the first of python3, python on PATH whose version it parses as 3.11 or newer. If neither qualifies, it refuses with exit 2 and a message naming what it found and telling you to add actions/setup-python.
  4. 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. -P keeps the working directory, which is your repository, off sys.path, so a veridor/ 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 sets http_proxy or https_proxy for a target that is not loopback, which Veridor then says in the step's log (proxies).
  5. Writes the outputs, appends the Markdown report to the job summary, and exits 0 deliberately, 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-baseline must 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 exits 3. 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.
  • openapi is required in drift mode. The matrix is projected from that inventory, and Veridor never discovers routes.
  • fail-on-drift defaults to opened here and to none on the veridor map CLI, for the same reason require-complete differs.
  • 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.