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 --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.
-
Set up Node.js
actions/setup-nodepinned to commit48b55a011bda, Node.js 24, with the package-manager cache disabled. -
Install locked production dependencies
npm ci --omit=dev --ignore-scriptsinstalls exactly the locked runtime dependencies and runs no install scripts. -
Run Settlens
Runs
settlens check --config … --out … --format … --fail-on …with your inputs, exportsallowed-hostsasSETTLENS_ALLOWED_HOSTS, and returns the CLI exit code as the step result. Thereport-directoryoutput is set on every run, passing or failing.
Inputs and outputs
| Input | Default | Description |
|---|---|---|
config | settlens.config.json | Path to a Settlens JSON configuration file. |
output | reports | Directory for JSON, HTML and SARIF evidence reports. |
formats | json,html,sarif | Comma-separated report formats. |
fail-on | high | Minimum failing severity that blocks CI. |
allowed-hosts | (empty) | Comma-separated exact hostnames allowed to receive custom headers. |
| Output | Description |
|---|---|
report-directory | Directory containing the generated evidence. |
Example workflow
This is the workflow shipped in examples/github-workflow.yml, shown unchanged.
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: readto check out,security-events: writeto 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-hostscomes 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 | The job fails when | Checks at or above this severity |
|---|---|---|
critical | A check fails with critical severity. No check the Action runs is critical by default: critical findings come from the authorized active probe. | 0 |
high | A check fails with high or critical severity. The default. | 19 |
medium | A check fails with medium severity or above. | 27 |
low | A check fails with low severity or above. | 29 |
none | Never. Reports are still written. | 0 |
| Code | Meaning |
|---|---|
| 0 | No failing check at or above the threshold. |
| 1 | At least one failing check at or above the threshold. |
| 2 | CLI 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.
| Settlens result | SARIF level | security-severity |
|---|---|---|
| fail, critical | error | 9.5 |
| fail, high | error | 8.0 |
| fail, medium | warning | 5.0 |
| fail, low | warning | 2.0 |
| warn, any severity | warning | From 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.
{
"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.
{
"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" }
}
]
}
- name: Assess x402 endpoints
uses: ./
env:
ASSESSMENT_TOKEN: ${{ secrets.ASSESSMENT_TOKEN }}
with:
config: settlens.config.json
allowed-hosts: merchant.example
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-SIGNATUREheader in the configuration is refused outright. - Outside the Action, set
SETTLENS_ALLOWED_HOSTSto 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.
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.
| Check | Default severity | Blocks at fail-on high |
|---|---|---|
transport.reachableEndpoint is reachable | high | Yes, when it fails |
transport.status_402Unauthenticated request requires payment | high | Yes, when it fails |
transport.payment_required_headerPAYMENT-REQUIRED header is present | high | Yes, when it fails |
transport.payment_required_encodingPAYMENT-REQUIRED decodes as Base64 JSON | high | Yes, when it fails |
transport.response_body_limitResponse body stays within the evidence limit | medium | No |
transport.httpsTransport protects payment metadata | medium | No |
transport.redirectPayment endpoint does not redirect unexpectedly | medium | No |
schema.official_x402_sdkOfficial x402 SDK accepts PaymentRequired | high | Yes, when it fails |
schema.payment_requiredPaymentRequired matches the x402 V2 core shape | high | Yes, when it fails |
binding.resource_urlPayment requirement is bound to the requested resource | high | Yes, when it fails |
schema.payment_requirementsAccepted payment requirements are well formed | high | Yes, when it fails |
schema.duplicate_requirementsPayment choices are unambiguous | low | No |
freshness.timeout_windowAuthorization windows are reasonably bounded | medium | No |
policy.schemeSchemes match the local policy | high | Yes, when it fails |
policy.networkNetworks match the local policy | high | Yes, when it fails |
policy.recipientRecipients match the local policy | high | Yes, when it fails |
policy.assetAssets match the local policy | high | Yes, when it fails |
policy.maximum_amountAmounts stay within the approved ceiling | high | Yes, when it fails |
stability.payment_requirementsPayment requirements remain stable | medium | No |
facilitator.reachableFacilitator /supported is reachable | high | Yes, when it fails |
facilitator.response_body_limitFacilitator capability response fits the evidence limit | high | Yes, when it fails |
facilitator.supported_statusFacilitator exposes capabilities successfully | high | Yes, when it fails |
facilitator.content_typeFacilitator capabilities use a JSON media type | medium | No |
facilitator.httpsFacilitator capability transport is protected | medium | No |
facilitator.supported_schemaSupportedResponse matches the x402 core shape | high | Yes, when it fails |
facilitator.v2_capabilityFacilitator advertises x402 V2 capabilities | high | Yes, when it fails |
facilitator.duplicate_kindsFacilitator capabilities are unambiguous | low | No |
facilitator.requirement_coverageFacilitator covers advertised payment requirements | high | Yes, when it fails |
facilitator.signer_coverageFacilitator publishes signer coverage | medium | No |
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
Put one endpoint behind the gate
Pilot participants get the repository, the Action and help writing the first policy for their endpoint.