x402-surface-check 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/bin/x402-surface-check.mjs +81 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,13 +13,14 @@ npx --yes x402-surface-check https://api.example.com/openapi.json report.md
|
|
|
13
13
|
npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/eth
|
|
14
14
|
npx --yes x402-surface-check --endpoint --method POST --body '{"prompt":"price CPI"}' https://api.example.com/paid-post
|
|
15
15
|
npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
16
|
+
npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## What It Checks
|
|
19
20
|
|
|
20
|
-
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
21
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
21
22
|
- Object-valued manifest endpoint query examples, public catalog/discovery GETs, and payment-bearing two-phase operations without treating expected public catalog reads as failed payment gates
|
|
22
|
-
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, or manifest-level OpenAPI links
|
|
23
|
+
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links
|
|
23
24
|
- OpenAPI `servers[]` base-path preservation, so `/paths` are probed through the documented gateway rather than the domain root
|
|
24
25
|
- OpenAPI query/path examples, JSON request-body examples, nested request schemas, local `$ref` request schemas, and explicit direct-endpoint bodies for safer no-payment probes
|
|
25
26
|
- OpenAPI paid-operation prioritization, so docs and discovery routes do not consume the probe limit before payment-bearing operations
|
|
@@ -36,6 +37,7 @@ npx --yes x402-surface-check --strict-cache https://api.example.com/openapi.json
|
|
|
36
37
|
- Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
|
|
37
38
|
- Browser CORS allowance for the requesting origin and `X-PAYMENT`, including the actual 402 challenge response
|
|
38
39
|
- Cache-Control posture on no-payment challenge responses, with P1 warnings for explicitly cacheable payment gates and optional strict-cache findings for missing policy headers
|
|
40
|
+
- Optional strict proof/idempotency posture: mutating paid routes that do not advertise payment-identifier idempotency, and payment challenges that do not advertise signed offer/receipt evidence
|
|
39
41
|
- Payment-enforcement headers on `200` responses, so public telemetry/free-trial endpoints do not accidentally advertise enforced x402 while returning content before a challenge
|
|
40
42
|
- Credential-like query params and URL userinfo inside public registry/discovery endpoint URLs, reported with redacted values so provider API keys and tokens are not repeated in scan output
|
|
41
43
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
@@ -75,6 +77,7 @@ x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
|
|
|
75
77
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
76
78
|
--limit <n> Maximum endpoints to probe, default 6
|
|
77
79
|
--strict-cache Flag missing Cache-Control on no-payment 402 responses
|
|
80
|
+
--strict-proof Flag missing idempotency and signed offer/receipt extensions
|
|
78
81
|
--json Print JSON instead of Markdown
|
|
79
82
|
--help Show usage
|
|
80
83
|
--version Show package version
|
|
@@ -86,6 +89,7 @@ Environment variables are also supported:
|
|
|
86
89
|
X402_CHECK_ORIGIN=https://example.com x402-surface-check https://api.example.com/openapi.json
|
|
87
90
|
X402_CHECK_LIMIT=12 x402-surface-check https://api.example.com/.well-known/x402
|
|
88
91
|
X402_STRICT_CACHE=1 x402-surface-check https://api.example.com/.well-known/x402
|
|
92
|
+
X402_STRICT_PROOF=1 x402-surface-check https://api.example.com/.well-known/x402
|
|
89
93
|
```
|
|
90
94
|
|
|
91
95
|
## Scope
|
|
@@ -23,6 +23,7 @@ Options:
|
|
|
23
23
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
24
24
|
--limit <n> Maximum endpoints to probe, default ${defaultLimit}
|
|
25
25
|
--strict-cache Flag missing Cache-Control on no-payment 402 responses
|
|
26
|
+
--strict-proof Flag missing idempotency and signed offer/receipt extensions
|
|
26
27
|
--json Print JSON instead of Markdown
|
|
27
28
|
--help Show this help
|
|
28
29
|
--version Show package version
|
|
@@ -40,6 +41,7 @@ function parseArgs(argv) {
|
|
|
40
41
|
bodyFile: process.env.X402_CHECK_BODY_FILE,
|
|
41
42
|
outputPath: '',
|
|
42
43
|
strictCache: process.env.X402_STRICT_CACHE === '1',
|
|
44
|
+
strictProof: process.env.X402_STRICT_PROOF === '1',
|
|
43
45
|
url: '',
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -60,6 +62,9 @@ function parseArgs(argv) {
|
|
|
60
62
|
else if (arg === '--strict-cache') {
|
|
61
63
|
args.strictCache = true
|
|
62
64
|
}
|
|
65
|
+
else if (arg === '--strict-proof') {
|
|
66
|
+
args.strictProof = true
|
|
67
|
+
}
|
|
63
68
|
else if (arg === '--method') {
|
|
64
69
|
args.method = String(argv[index + 1] ?? '').toUpperCase()
|
|
65
70
|
index += 1
|
|
@@ -173,10 +178,22 @@ function openApiServerBaseUrl(document, sourceUrl) {
|
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
function linkedDiscoveryUrl(document, sourceUrl) {
|
|
181
|
+
const discovery = document?.discovery && typeof document.discovery === 'object'
|
|
182
|
+
? document.discovery
|
|
183
|
+
: {}
|
|
176
184
|
const rawUrl = document?.discovery_url
|
|
177
185
|
?? document?.discoveryUrl
|
|
178
186
|
?? document?.resources_url
|
|
179
187
|
?? document?.resourcesUrl
|
|
188
|
+
?? (typeof document?.discovery === 'string' ? document.discovery : undefined)
|
|
189
|
+
?? discovery.x402_json
|
|
190
|
+
?? discovery.x402Json
|
|
191
|
+
?? discovery.resources_json
|
|
192
|
+
?? discovery.resourcesJson
|
|
193
|
+
?? discovery.resources
|
|
194
|
+
?? discovery.openapi
|
|
195
|
+
?? discovery.openapi_url
|
|
196
|
+
?? discovery.openapiUrl
|
|
180
197
|
?? (/^(https?:\/\/|\/)/i.test(String(document?.openapi ?? '')) ? document.openapi : '')
|
|
181
198
|
if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''
|
|
182
199
|
return endpointUrl(rawUrl, documentBaseUrl(document, sourceUrl), sourceUrl)
|
|
@@ -326,6 +343,7 @@ function manifestEndpointPaymentSignal(endpoint) {
|
|
|
326
343
|
if (!endpoint || typeof endpoint !== 'object') return 0
|
|
327
344
|
if (Number(endpoint.phase1_response?.status) === 402) return 2
|
|
328
345
|
if (/payment-required|x-payment|402/i.test(String(endpoint.phase1_response?.header ?? ''))) return 2
|
|
346
|
+
if (/^\$?\d+(\.\d+)?/.test(String(endpoint.price ?? endpoint.cost ?? endpoint.amount ?? ''))) return 1
|
|
329
347
|
if (/payment|required|402/i.test(String(endpoint.description ?? ''))) return 1
|
|
330
348
|
if (endpoint.accepts || endpoint.schemes || endpoint.payment || endpoint['x-payment-info']) return 1
|
|
331
349
|
return 0
|
|
@@ -397,10 +415,28 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
397
415
|
})
|
|
398
416
|
}
|
|
399
417
|
}
|
|
400
|
-
|
|
401
|
-
|
|
418
|
+
const endpointMaps = []
|
|
419
|
+
if (!Array.isArray(document.endpoints) && document.endpoints && typeof document.endpoints === 'object') {
|
|
420
|
+
endpointMaps.push(document.endpoints)
|
|
421
|
+
}
|
|
422
|
+
if (document.tools && typeof document.tools === 'object') {
|
|
423
|
+
endpointMaps.push(document.tools)
|
|
424
|
+
}
|
|
425
|
+
for (const endpointMap of endpointMaps) {
|
|
426
|
+
for (const [key, endpoint] of Object.entries(endpointMap)) {
|
|
427
|
+
if (typeof endpoint === 'string') {
|
|
428
|
+
const methodMatch = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
|
|
429
|
+
const rawPath = methodMatch?.[2] ?? key
|
|
430
|
+
entries.push({
|
|
431
|
+
name: String(rawPath).split('/').filter(Boolean).at(-1) ?? key,
|
|
432
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
433
|
+
method: String(methodMatch?.[1] ?? 'GET').toUpperCase(),
|
|
434
|
+
})
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
402
437
|
if (!endpoint || typeof endpoint !== 'object') continue
|
|
403
|
-
const
|
|
438
|
+
const keyPath = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)?.[2] ?? key
|
|
439
|
+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path ?? keyPath
|
|
404
440
|
if (!rawPath) continue
|
|
405
441
|
const method = String(endpoint.method ?? 'POST').toUpperCase()
|
|
406
442
|
const paymentSignal = manifestEndpointPaymentSignal(endpoint)
|
|
@@ -685,6 +721,23 @@ function challengeAccepts(result) {
|
|
|
685
721
|
return []
|
|
686
722
|
}
|
|
687
723
|
|
|
724
|
+
function extensionKeys(container) {
|
|
725
|
+
const extensions = container?.extensions
|
|
726
|
+
if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return []
|
|
727
|
+
return Object.keys(extensions)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function challengeExtensionKeys(result) {
|
|
731
|
+
return new Set([
|
|
732
|
+
...extensionKeys(result.body.json),
|
|
733
|
+
...challengeAccepts(result).flatMap(accept => extensionKeys(accept)),
|
|
734
|
+
].map(key => key.toLowerCase()))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function hasChallengeExtension(result, pattern) {
|
|
738
|
+
return [...challengeExtensionKeys(result)].some(key => pattern.test(key))
|
|
739
|
+
}
|
|
740
|
+
|
|
688
741
|
function acceptAmountValue(accept) {
|
|
689
742
|
return accept.maxAmountRequired ?? accept.maxAmount ?? accept.amount ?? ''
|
|
690
743
|
}
|
|
@@ -898,6 +951,10 @@ function looksLikeOperationalHealthEndpoint(result) {
|
|
|
898
951
|
return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
|
|
899
952
|
}
|
|
900
953
|
|
|
954
|
+
function isMutatingMethod(method) {
|
|
955
|
+
return !['GET', 'HEAD', 'OPTIONS'].includes(String(method ?? 'POST').toUpperCase())
|
|
956
|
+
}
|
|
957
|
+
|
|
901
958
|
function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
|
|
902
959
|
const document = documentResult.body.json ?? {}
|
|
903
960
|
const findings = []
|
|
@@ -1005,6 +1062,15 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1005
1062
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
1006
1063
|
findings.push(`P3 - ${result.name} payment challenge response did not expose Cache-Control; for payment-gated routes, document or send no-store/private cache policy and confirm paid 200 responses are never shared-cacheable.`)
|
|
1007
1064
|
}
|
|
1065
|
+
|
|
1066
|
+
if (options.strictProof) {
|
|
1067
|
+
if (isMutatingMethod(result.method) && !hasChallengeExtension(result, /payment[-_]?identifier|idempotenc/)) {
|
|
1068
|
+
findings.push(`P2 - ${result.name} is a mutating paid route but does not advertise payment-identifier idempotency; retries after network or client failures can duplicate charges or lose paid responses without a shared payment ID.`)
|
|
1069
|
+
}
|
|
1070
|
+
if (!hasChallengeExtension(result, /offer[-_]?receipt|signed[-_]?(offer|receipt)/)) {
|
|
1071
|
+
findings.push(`P3 - ${result.name} challenge does not advertise signed offer/receipt metadata; clients have weaker proof of committed terms and service delivery for audits or disputes.`)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1008
1074
|
}
|
|
1009
1075
|
|
|
1010
1076
|
for (const result of preflightResults) {
|
|
@@ -1072,6 +1138,12 @@ function groupedFindingLabel(finding) {
|
|
|
1072
1138
|
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1073
1139
|
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1074
1140
|
}
|
|
1141
|
+
if (/does not advertise payment-identifier idempotency/.test(finding)) {
|
|
1142
|
+
return 'P2 - Mutating paid routes do not advertise payment-identifier idempotency.'
|
|
1143
|
+
}
|
|
1144
|
+
if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
|
|
1145
|
+
return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
|
|
1146
|
+
}
|
|
1075
1147
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1076
1148
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1077
1149
|
}
|
|
@@ -1111,6 +1183,11 @@ function referenceGuides(findings) {
|
|
|
1111
1183
|
add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
|
|
1112
1184
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1113
1185
|
}
|
|
1186
|
+
if (/payment-identifier|idempotency|signed offer|signed offer\/receipt|service delivery|audits|disputes/i.test(text)) {
|
|
1187
|
+
add('x402 Payment-Identifier', 'https://docs.x402.org/extensions/payment-identifier')
|
|
1188
|
+
add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
|
|
1189
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1190
|
+
}
|
|
1114
1191
|
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1115
1192
|
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1116
1193
|
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
@@ -1243,6 +1320,7 @@ async function runCheck(options) {
|
|
|
1243
1320
|
}
|
|
1244
1321
|
report.findings = findingList(document, challenges, preflights, entries, {
|
|
1245
1322
|
strictCache: options.strictCache,
|
|
1323
|
+
strictProof: options.strictProof,
|
|
1246
1324
|
})
|
|
1247
1325
|
return report
|
|
1248
1326
|
}
|