x402-surface-check 0.2.26 → 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 +7 -2
- package/bin/x402-surface-check.mjs +137 -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,7 +37,9 @@ 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
|
|
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
|
|
40
43
|
- Grouped finding summaries for repeated route-wide issues, so large manifests keep the patch order readable
|
|
41
44
|
- Contextual reference guides for CORS, cache policy, Worker gates, resource echo, validation/auth ordering, and the May 2026 x402 attack-control map
|
|
42
45
|
- Over-broad public method surfaces
|
|
@@ -74,6 +77,7 @@ x402-surface-check --endpoint --method POST <paid-endpoint-url> [output.md]
|
|
|
74
77
|
--origin <url> Origin to use for browser-style CORS preflight
|
|
75
78
|
--limit <n> Maximum endpoints to probe, default 6
|
|
76
79
|
--strict-cache Flag missing Cache-Control on no-payment 402 responses
|
|
80
|
+
--strict-proof Flag missing idempotency and signed offer/receipt extensions
|
|
77
81
|
--json Print JSON instead of Markdown
|
|
78
82
|
--help Show usage
|
|
79
83
|
--version Show package version
|
|
@@ -85,6 +89,7 @@ Environment variables are also supported:
|
|
|
85
89
|
X402_CHECK_ORIGIN=https://example.com x402-surface-check https://api.example.com/openapi.json
|
|
86
90
|
X402_CHECK_LIMIT=12 x402-surface-check https://api.example.com/.well-known/x402
|
|
87
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
|
|
88
93
|
```
|
|
89
94
|
|
|
90
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
|
}
|
|
@@ -811,6 +864,55 @@ function looksLikeLocalResourceUrl(value) {
|
|
|
811
864
|
}
|
|
812
865
|
}
|
|
813
866
|
|
|
867
|
+
const secretQueryParamPattern =
|
|
868
|
+
/^(?:access[_-]?token|api[_-]?key|auth|authorization|bearer|client[_-]?secret|code|key|password|private[_-]?key|secret|session|sig|signature|token|jwt)$/i
|
|
869
|
+
|
|
870
|
+
function redactedCredentialUrl(value) {
|
|
871
|
+
if (!/^https?:\/\//i.test(String(value ?? ''))) return null
|
|
872
|
+
try {
|
|
873
|
+
const url = new URL(value)
|
|
874
|
+
let changed = false
|
|
875
|
+
|
|
876
|
+
if (url.username || url.password) {
|
|
877
|
+
if (url.username) url.username = 'REDACTED'
|
|
878
|
+
if (url.password) url.password = 'REDACTED'
|
|
879
|
+
changed = true
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
for (const [name] of Array.from(url.searchParams.entries())) {
|
|
883
|
+
if (secretQueryParamPattern.test(name)) {
|
|
884
|
+
url.searchParams.set(name, 'REDACTED')
|
|
885
|
+
changed = true
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return changed ? url.toString() : null
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return null
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function publicUrlCredentialFindings(value, path = 'document', depth = 0) {
|
|
897
|
+
if (depth > 8 || value === null || value === undefined) return []
|
|
898
|
+
if (typeof value === 'string') {
|
|
899
|
+
const redacted = redactedCredentialUrl(value)
|
|
900
|
+
return redacted
|
|
901
|
+
? [`P2 - Public document exposes credential-like URL material at ${path}: ${redacted}. Move provider tokens, signatures, sessions, or API keys out of registry-visible endpoint URLs.`]
|
|
902
|
+
: []
|
|
903
|
+
}
|
|
904
|
+
if (Array.isArray(value)) {
|
|
905
|
+
return value.flatMap((item, index) => publicUrlCredentialFindings(item, `${path}[${index}]`, depth + 1))
|
|
906
|
+
}
|
|
907
|
+
if (typeof value === 'object') {
|
|
908
|
+
return Object.entries(value).flatMap(([key, item]) => {
|
|
909
|
+
const safeKey = /^[a-zA-Z_$][\w$-]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`
|
|
910
|
+
return publicUrlCredentialFindings(item, `${path}${safeKey}`, depth + 1)
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
return []
|
|
914
|
+
}
|
|
915
|
+
|
|
814
916
|
function cachePolicy(headers = {}) {
|
|
815
917
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
816
918
|
}
|
|
@@ -849,6 +951,10 @@ function looksLikeOperationalHealthEndpoint(result) {
|
|
|
849
951
|
return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
|
|
850
952
|
}
|
|
851
953
|
|
|
954
|
+
function isMutatingMethod(method) {
|
|
955
|
+
return !['GET', 'HEAD', 'OPTIONS'].includes(String(method ?? 'POST').toUpperCase())
|
|
956
|
+
}
|
|
957
|
+
|
|
852
958
|
function findingList(documentResult, challengeResults, preflightResults, entries, options = {}) {
|
|
853
959
|
const document = documentResult.body.json ?? {}
|
|
854
960
|
const findings = []
|
|
@@ -863,6 +969,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
863
969
|
if (!documentResult.body.json) {
|
|
864
970
|
findings.push(`P1 - Document did not return parseable JSON; content begins: ${documentResult.body.text.slice(0, 80).replace(/\s+/g, ' ')}.`)
|
|
865
971
|
}
|
|
972
|
+
else {
|
|
973
|
+
findings.push(...publicUrlCredentialFindings(document))
|
|
974
|
+
}
|
|
866
975
|
|
|
867
976
|
if (entries.length === 0) {
|
|
868
977
|
findings.push('P1 - Document does not expose any manifest, OpenAPI, item, category, or resource endpoints for no-payment probes.')
|
|
@@ -953,6 +1062,15 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
953
1062
|
else if (options.strictCache && !cachePolicy(result.headers)) {
|
|
954
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.`)
|
|
955
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
|
+
}
|
|
956
1074
|
}
|
|
957
1075
|
|
|
958
1076
|
for (const result of preflightResults) {
|
|
@@ -1020,6 +1138,12 @@ function groupedFindingLabel(finding) {
|
|
|
1020
1138
|
if (/payment challenge response is explicitly cacheable/.test(finding)) {
|
|
1021
1139
|
return 'P1 - Payment challenge responses are explicitly cacheable.'
|
|
1022
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
|
+
}
|
|
1023
1147
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1024
1148
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1025
1149
|
}
|
|
@@ -1059,6 +1183,15 @@ function referenceGuides(findings) {
|
|
|
1059
1183
|
add('x402 Surface Check notes', 'https://tateprograms.com/x402-surface-check.html')
|
|
1060
1184
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1061
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
|
+
}
|
|
1191
|
+
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1192
|
+
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1193
|
+
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
1194
|
+
}
|
|
1062
1195
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
1063
1196
|
}
|
|
1064
1197
|
|
|
@@ -1187,6 +1320,7 @@ async function runCheck(options) {
|
|
|
1187
1320
|
}
|
|
1188
1321
|
report.findings = findingList(document, challenges, preflights, entries, {
|
|
1189
1322
|
strictCache: options.strictCache,
|
|
1323
|
+
strictProof: options.strictProof,
|
|
1190
1324
|
})
|
|
1191
1325
|
return report
|
|
1192
1326
|
}
|