Block merges that break x402 payments

The Settlens GitHub Action runs the passive Settlens checks on every pull request. It fails the job when a finding reaches your severity threshold and writes JSON, HTML and SARIF reports as evidence.

Real output against the demo target, with a policy and a facilitator configured. The facilitator does not support the advertised network, so the job fails with exit code 1.

settlens check (what the Action runs)
settlens check --config settlens.config.json
Settlens x402 assessment c7bbc707
Targets 1 · Active 0 · Settlement calls 0
 
paid-api  GET http://127.0.0.1:4402/secure
26 pass · 1 fail · 0 warn · 0 skip
  FAIL high     Facilitator covers advertised payment requirements
       1 advertised payment choice(s) are absent from facilitator capabilities.
 
Total: 26 pass · 1 fail · 0 warn · 0 skip

In short

The Settlens GitHub Action runs the passive x402 checks of your configuration in pull request workflows. It fails the job when a failing check reaches the fail-on severity, high by default, and writes JSON, HTML and SARIF reports that the example workflow uploads to code scanning.

What the Action does

  • The same checks as Assess

    It runs settlens check on your configuration: the 402 handshake, schema, binding, drift, your policy and, when configured, facilitator coverage.

    What Assess checks
  • A severity threshold

    fail-on decides which failures block the job. The default, high, blocks on high and critical findings.

  • SARIF, JSON and HTML

    One run writes all three formats to the output directory, with a SHA-256 integrity digest in the JSON report.

  • Secrets go only to approved hosts

    Custom headers are only sent to hosts listed in allowed-hosts, an input kept outside the configuration file.

  • Pinned and minimal

    Node.js 24 through a commit-pinned setup action, then a locked production install with install scripts disabled.

  • Passive only

    The Action never sends a payment signature. The authorized active probe is a manual, approved step.

How it works

The composite Action has three steps, read here from action.yml.

  1. Set up Node.js

    actions/setup-node pinned to commit 48b55a011bda, Node.js 24, with the package-manager cache disabled.

  2. Install locked production dependencies

    npm ci --omit=dev --ignore-scripts installs exactly the locked runtime dependencies and runs no install scripts.

  3. Run Settlens

    Runs settlens check --config … --out … --format … --fail-on … with your inputs, exports allowed-hosts as SETTLENS_ALLOWED_HOSTS, and returns the CLI exit code as the step result. The report-directory output is set on every run, passing or failing.

Inputs and outputs

Inputs (from action.yml)
InputDefaultDescription
configsettlens.config.jsonPath to a Settlens JSON configuration file.
outputreportsDirectory for JSON, HTML and SARIF evidence reports.
formatsjson,html,sarifComma-separated report formats.
fail-onhighMinimum failing severity that blocks CI.
allowed-hosts(empty)Comma-separated exact hostnames allowed to receive custom headers.
Outputs (from action.yml)
OutputDescription
report-directoryDirectory containing the generated evidence.

Example workflow

This is the workflow shipped in examples/github-workflow.yml, shown unchanged.

YAML.github/workflows/settlens.yml
name: Settlens

on:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read
  security-events: write

jobs:
  x402-assessment:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

      # Until Settlens is published as a remote action, this repository can run
      # its local composite action directly.
      - name: Assess x402 endpoints
        uses: ./
        with:
          config: settlens.config.json
          output: reports
          formats: json,html,sarif
          fail-on: high
          # Set this from a repository variable when the config uses secrets.
          allowed-hosts: ${{ vars.SETTLENS_ALLOWED_HOSTS }}

      - name: Upload SARIF to code scanning
        if: >-
          always() &&
          hashFiles('reports/**/*.sarif') != '' &&
          (github.event_name != 'pull_request' ||
          github.event.pull_request.head.repo.full_name == github.repository)
        uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a
        with:
          sarif_file: reports

      - name: Upload evidence
        if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
        with:
          name: settlens-reports
          path: reports/
          if-no-files-found: error
          retention-days: 30

What to notice

  • Permissions. contents: read to check out, security-events: write to upload SARIF. Nothing else.
  • Pinned actions. Third-party actions are referenced by commit SHA, not by a movable tag.
  • uses: ./ runs the composite Action from the Settlens checkout itself.
  • SARIF upload runs even when the gate fails, but is skipped for pull requests from forks, which cannot write security events. See SARIF and code scanning.
  • Evidence (JSON, HTML and SARIF) is uploaded as an artifact on every run and kept for 30 days; the step fails if no report was written.
  • allowed-hosts comes from a repository variable, so a pull request that edits the configuration cannot add a host.

Choose the threshold

Only failing checks count. Warnings, such as drift between requests or a plain-HTTP remote endpoint, are reported in every format but never fail the job. Today every medium- and low-severity check the Action runs reports a warning, not a failure, so fail-on: low or fail-on: medium blocks exactly the same findings as fail-on: high.

fail-on values
fail-onThe job fails whenChecks at or above this severity
criticalA check fails with critical severity. No check the Action runs is critical by default: critical findings come from the authorized active probe.0
highA check fails with high or critical severity. The default.19
mediumA check fails with medium severity or above.27
lowA check fails with low severity or above.29
noneNever. Reports are still written.0
Exit codes
CodeMeaning
0No failing check at or above the threshold.
1At least one failing check at or above the threshold.
2CLI or configuration error, for example an unknown fail-on value or a custom header without an allowed host.

SARIF output

Each failing or warning check becomes one SARIF result whose rule id is the Settlens check id. Passing and skipped checks are left out.

How results map to SARIF
Settlens resultSARIF levelsecurity-severity
fail, criticalerror9.5
fail, higherror8.0
fail, mediumwarning5.0
fail, lowwarning2.0
warn, any severitywarningFrom the severity, as above

Every rule carries the tags security, x402 and agentic-payments, and the run records the JSON report's integrity digest so a SARIF result can be traced back to the exact evidence.

The results describe HTTP endpoints, not source lines, so each one is anchored at line 1 of the configuration file that declared the target, the config input of the Action. Code scanning shows Settlens findings as alerts on that file, with the endpoint in the message, and each target keeps its own fingerprint.

Code scanning on private repositories

On private repositories, code scanning requires GitHub Code Security; without it the upload step fails. GitHub's documented upload workflow also grants actions: read there, which the example above leaves out. The gate decision comes from the Settlens step's exit code, not from the upload, so add continue-on-error: true to the upload step if it should never fail the job on its own.

JSONOne SARIF result (demo bypass)
{
  "ruleId": "security.invalid_signature_fail_closed",
  "level": "error",
  "message": {
    "text": "The endpoint returned HTTP 200 after an invalid payment authorization. Target: GET http://127.0.0.1:4402/bypass"
  },
  "locations": [
    {
      "physicalLocation": {
        "artifactLocation": { "uri": "settlens.config.json", "uriBaseId": "%SRCROOT%" },
        "region": { "startLine": 1 }
      }
    }
  ],
  "partialFingerprints": { "primaryLocationLineHash": "aee170b410d68e01:1" },
  "properties": {
    "status": "fail",
    "severity": "critical",
    "target": "http://127.0.0.1:4402/bypass",
    "recommendation": "Block resource execution unless cryptographic verification and settlement succeed. Treat this as a potential payment bypass.",
    "reportId": "d2145529"
  }
}

Authenticated endpoints without leaking secrets

Configuration values can reference environment variables. Any target with custom headers must have its host allowlisted separately, so an edited configuration cannot forward a CI secret to a new host.

JSONsettlens.config.json
{
  "version": 1,
  "failOn": "high",
  "targets": [
    {
      "name": "paid-api",
      "url": "https://merchant.example/api/data",
      "headers": { "Authorization": "Bearer ${ASSESSMENT_TOKEN}" },
      "expected": { "networks": ["eip155:8453"], "maxAmount": "10000" }
    }
  ]
}
YAMLWorkflow step
- name: Assess x402 endpoints
  uses: ./
  env:
    ASSESSMENT_TOKEN: ${{ secrets.ASSESSMENT_TOKEN }}
  with:
    config: settlens.config.json
    allowed-hosts: merchant.example
The allowlist is enforced before any request
settlens scan http://127.0.0.1:4402/secure \
    --header "Authorization: Bearer test"
Settlens error: Custom headers require an explicit allowed host. Add --allow-host for: 127.0.0.1
SETTLENS_ALLOWED_HOSTS=127.0.0.1 settlens scan http://127.0.0.1:4402/secure \
    --header "Authorization: Bearer test" --no-write
…
Total: 13 pass · 0 fail · 0 warn · 0 skip
  • ${ASSESSMENT_TOKEN} is expanded at run time; a missing variable stops the run.
  • Reports keep request header names only, never values.
  • A PAYMENT-SIGNATURE header in the configuration is refused outright.
  • Outside the Action, set SETTLENS_ALLOWED_HOSTS to a comma-separated list of exact hostnames, as above.

Outside GitHub: the container

The same CLI ships as a container image for other CI systems and isolated hosts. The base image is pinned by digest, dependencies are installed from the lockfile with install scripts disabled, npm is removed from the runtime image and the process runs as the unprivileged node user. The working directory is /workspace.

Mount the configuration read-only and a reports directory writable by UID 1000. The container exits with the same codes as the CLI, so any CI system can gate on it.

ShellBuild and run
docker build -t settlens:local .
docker run --rm settlens:local --version
docker run --rm \
  -v "$PWD/config:/workspace/config:ro" \
  -v "$PWD/evidence:/workspace/reports" \
  settlens:local check --config config/settlens.config.json

Checks the gate can run

Rendered from the check catalog. Facilitator checks run only for targets with a facilitator; policy checks only when their field is set. See the check reference for fixes.

29 checks the Action can report
CheckDefault severityBlocks at fail-on high
transport.reachable
Endpoint is reachable
highYes, when it fails
transport.status_402
Unauthenticated request requires payment
highYes, when it fails
transport.payment_required_header
PAYMENT-REQUIRED header is present
highYes, when it fails
transport.payment_required_encoding
PAYMENT-REQUIRED decodes as Base64 JSON
highYes, when it fails
transport.response_body_limit
Response body stays within the evidence limit
mediumNo
transport.https
Transport protects payment metadata
mediumNo
transport.redirect
Payment endpoint does not redirect unexpectedly
mediumNo
schema.official_x402_sdk
Official x402 SDK accepts PaymentRequired
highYes, when it fails
schema.payment_required
PaymentRequired matches the x402 V2 core shape
highYes, when it fails
binding.resource_url
Payment requirement is bound to the requested resource
highYes, when it fails
schema.payment_requirements
Accepted payment requirements are well formed
highYes, when it fails
schema.duplicate_requirements
Payment choices are unambiguous
lowNo
freshness.timeout_window
Authorization windows are reasonably bounded
mediumNo
policy.scheme
Schemes match the local policy
highYes, when it fails
policy.network
Networks match the local policy
highYes, when it fails
policy.recipient
Recipients match the local policy
highYes, when it fails
policy.asset
Assets match the local policy
highYes, when it fails
policy.maximum_amount
Amounts stay within the approved ceiling
highYes, when it fails
stability.payment_requirements
Payment requirements remain stable
mediumNo
facilitator.reachable
Facilitator /supported is reachable
highYes, when it fails
facilitator.response_body_limit
Facilitator capability response fits the evidence limit
highYes, when it fails
facilitator.supported_status
Facilitator exposes capabilities successfully
highYes, when it fails
facilitator.content_type
Facilitator capabilities use a JSON media type
mediumNo
facilitator.https
Facilitator capability transport is protected
mediumNo
facilitator.supported_schema
SupportedResponse matches the x402 core shape
highYes, when it fails
facilitator.v2_capability
Facilitator advertises x402 V2 capabilities
highYes, when it fails
facilitator.duplicate_kinds
Facilitator capabilities are unambiguous
lowNo
facilitator.requirement_coverage
Facilitator covers advertised payment requirements
highYes, when it fails
facilitator.signer_coverage
Facilitator publishes signer coverage
mediumNo

What the CI gate does not do

  • No active probe. The Action runs passive checks only. Prove fail-closed behavior with an authorized probe, run deliberately.
  • Not on the Marketplace. The Action ships in the private Settlens repository given to pilot participants and runs from that checkout.
  • Network access required. The runner must reach your endpoints. Staging behind a VPN needs a runner inside it.
  • Warnings never block. Drift, redirects, plain HTTP and other warnings appear in the reports but do not fail the job.
  • Not signed automatically. Reports carry an integrity digest; sign them with evidence attestations when they leave your team.
  • A green build is a sample. It shows the endpoint passed at build time. Use Monitor to keep watching it in production.

Questions about the CI gate

Which findings fail the job by default?

Failing checks with high or critical severity. Set fail-on to critical, medium, low or none to change that. Warnings never fail the job.

Does the Action send payments or need a wallet?

No. It runs passive checks with ordinary unauthenticated requests. It never sends a payment signature, never calls /settle and needs no key.

Can I use it in GitLab CI, Jenkins or another system?

Yes, through the CLI or the container image: run settlens check and gate on the exit code (0 pass, 1 findings at the threshold, 2 error).

Where do the reports go?

To the output directory (reports by default), which every run, passing or failing, also exposes as the report-directory output. The example workflow uploads them as a build artifact kept for 30 days.

Why is allowed-hosts separate from the configuration?

So that a pull request that changes the configuration cannot redirect a CI secret to a host you did not approve. The allowlist lives in a repository variable instead.

Sources

  1. Static Analysis Results Interchange Format (SARIF) Version 2.1.0OASIS · docs.oasis-open.org
  2. Uploading a SARIF file to GitHubGitHub Docs · docs.github.com

Put one endpoint behind the gate

Pilot participants get the repository, the Action and help writing the first policy for their endpoint.