x402-surface-check 0.2.27 → 0.2.30
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 +99 -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,46 @@ 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' && !Array.isArray(document.tools)) {
|
|
423
|
+
endpointMaps.push(document.tools)
|
|
424
|
+
}
|
|
425
|
+
if (Array.isArray(document.tools)) {
|
|
426
|
+
for (const tool of document.tools) {
|
|
427
|
+
if (!tool || typeof tool !== 'object') continue
|
|
428
|
+
const rawPath = tool.url ?? tool.endpoint ?? tool.path
|
|
429
|
+
if (!rawPath) continue
|
|
430
|
+
const method = String(tool.method ?? 'POST').toUpperCase()
|
|
431
|
+
const paymentSignal = manifestEndpointPaymentSignal(tool)
|
|
432
|
+
const hasPathParameters = /\{[^}]+\}/.test(String(rawPath))
|
|
433
|
+
if (paymentSignal === 0 && (method !== 'GET' || hasPathParameters)) continue
|
|
434
|
+
entries.push({
|
|
435
|
+
name: tool.id ?? tool.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
|
|
436
|
+
url: manifestEndpointUrl(rawPath, tool, baseUrl, sourceUrl),
|
|
437
|
+
method,
|
|
438
|
+
requestBody: manifestEndpointBody(tool, document),
|
|
439
|
+
publicDiscovery: paymentSignal === 0,
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const endpointMap of endpointMaps) {
|
|
444
|
+
for (const [key, endpoint] of Object.entries(endpointMap)) {
|
|
445
|
+
if (typeof endpoint === 'string') {
|
|
446
|
+
const methodMatch = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
|
|
447
|
+
const rawPath = methodMatch?.[2] ?? key
|
|
448
|
+
entries.push({
|
|
449
|
+
name: String(rawPath).split('/').filter(Boolean).at(-1) ?? key,
|
|
450
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
451
|
+
method: String(methodMatch?.[1] ?? 'GET').toUpperCase(),
|
|
452
|
+
})
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
402
455
|
if (!endpoint || typeof endpoint !== 'object') continue
|
|
403
|
-
const
|
|
456
|
+
const keyPath = key.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(\S+)/i)?.[2] ?? key
|
|
457
|
+
const rawPath = endpoint.url ?? endpoint.endpoint ?? endpoint.path ?? keyPath
|
|
404
458
|
if (!rawPath) continue
|
|
405
459
|
const method = String(endpoint.method ?? 'POST').toUpperCase()
|
|
406
460
|
const paymentSignal = manifestEndpointPaymentSignal(endpoint)
|
|
@@ -685,6 +739,23 @@ function challengeAccepts(result) {
|
|
|
685
739
|
return []
|
|
686
740
|
}
|
|
687
741
|
|
|
742
|
+
function extensionKeys(container) {
|
|
743
|
+
const extensions = container?.extensions
|
|
744
|
+
if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return []
|
|
745
|
+
return Object.keys(extensions)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function challengeExtensionKeys(result) {
|
|
749
|
+
return new Set([
|
|
750
|
+
...extensionKeys(result.body.json),
|
|
751
|
+
...challengeAccepts(result).flatMap(accept => extensionKeys(accept)),
|
|
752
|
+
].map(key => key.toLowerCase()))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function hasChallengeExtension(result, pattern) {
|
|
756
|
+
return [...challengeExtensionKeys(result)].some(key => pattern.test(key))
|
|
757
|
+
}
|
|
758
|
+
|
|
688
759
|
function acceptAmountValue(accept) {
|
|
689
760
|
return accept.maxAmountRequired ?? accept.maxAmount ?? accept.amount ?? ''
|
|
690
761
|
}
|
|
@@ -898,6 +969,10 @@ function looksLikeOperationalHealthEndpoint(result) {
|
|
|
898
969
|
return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
|
|
899
970
|
}
|
|
900
971
|
|
|
972
|
+
function isMutatingMethod(method) {
|
|
973
|
+
return !['GET', 'HEAD', 'OPTIONS'].includes(String(method ?? 'POST').toUpperCase())
|
|
974
|
+
}
|
|
975
|
+
|
|
901
976
|
function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
|
|
902
977
|
const document = documentResult.body.json ?? {}
|
|
903
978
|
const findings = []
|
|
@@ -1005,6 +1080,15 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1005
1080
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
1006
1081
|
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
1082
|
}
|
|
1083
|
+
|
|
1084
|
+
if (options.strictProof) {
|
|
1085
|
+
if (isMutatingMethod(result.method) && !hasChallengeExtension(result, /payment[-_]?identifier|idempotenc/)) {
|
|
1086
|
+
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.`)
|
|
1087
|
+
}
|
|
1088
|
+
if (!hasChallengeExtension(result, /offer[-_]?receipt|signed[-_]?(offer|receipt)/)) {
|
|
1089
|
+
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.`)
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1008
1092
|
}
|
|
1009
1093
|
|
|
1010
1094
|
for (const result of preflightResults) {
|
|
@@ -1072,6 +1156,12 @@ function groupedFindingLabel(finding) {
|
|
|
1072
1156
|
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1073
1157
|
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1074
1158
|
}
|
|
1159
|
+
if (/does not advertise payment-identifier idempotency/.test(finding)) {
|
|
1160
|
+
return 'P2 - Mutating paid routes do not advertise payment-identifier idempotency.'
|
|
1161
|
+
}
|
|
1162
|
+
if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
|
|
1163
|
+
return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
|
|
1164
|
+
}
|
|
1075
1165
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1076
1166
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1077
1167
|
}
|
|
@@ -1111,6 +1201,11 @@ function referenceGuides(findings) {
|
|
|
1111
1201
|
add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
|
|
1112
1202
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1113
1203
|
}
|
|
1204
|
+
if (/payment-identifier|idempotency|signed offer|signed offer\/receipt|service delivery|audits|disputes/i.test(text)) {
|
|
1205
|
+
add('x402 Payment-Identifier', 'https://docs.x402.org/extensions/payment-identifier')
|
|
1206
|
+
add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
|
|
1207
|
+
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1208
|
+
}
|
|
1114
1209
|
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1115
1210
|
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1116
1211
|
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
@@ -1243,6 +1338,7 @@ async function runCheck(options) {
|
|
|
1243
1338
|
}
|
|
1244
1339
|
report.findings = findingList(document, challenges, preflights, entries, {
|
|
1245
1340
|
strictCache: options.strictCache,
|
|
1341
|
+
strictProof: options.strictProof,
|
|
1246
1342
|
})
|
|
1247
1343
|
return report
|
|
1248
1344
|
}
|