x402-surface-check 0.2.36 → 0.2.38
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 +2 -0
- package/bin/x402-surface-check.mjs +106 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
30
30
|
- No-payment HTTP 402 challenge shape
|
|
31
31
|
- x402 v1 and v2 price fields, including `accepts[]` and `schemes[]` challenge arrays
|
|
32
32
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
33
|
+
- x402 challenges nested inside framework error wrappers such as FastAPI-style `{"detail": {...}}`
|
|
33
34
|
- MPP descriptor-only 402s that advertise discovery headers but do not return a machine-readable payment retry challenge
|
|
34
35
|
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
|
|
35
36
|
- `asset` or token metadata, `network`, and `payTo`
|
|
@@ -39,6 +40,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
39
40
|
- HTTPS resource URLs and stable resource metadata
|
|
40
41
|
- Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
|
|
41
42
|
- Timeout/expiry metadata on challenges, so payment capabilities have an explicit bounded freshness window
|
|
43
|
+
- Payment-metadata privacy checks for sensitive resource query context, email/SSN/token-like values, prompt/private-context strings, and credential-like URLs in body or header-carried challenges
|
|
42
44
|
- Browser CORS allowance for the requesting origin, common x402/MPP retry headers, and exposed challenge/session headers on the actual 402 response
|
|
43
45
|
- 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
|
|
44
46
|
- 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
|
|
@@ -648,6 +648,29 @@ function parseX402Authenticate(value) {
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
function bodyChallengeJson(value) {
|
|
652
|
+
if (!value || typeof value !== 'object') return null
|
|
653
|
+
if (Array.isArray(value.accepts) || Array.isArray(value.schemes)) return value
|
|
654
|
+
|
|
655
|
+
const candidates = [
|
|
656
|
+
value.detail,
|
|
657
|
+
value.error,
|
|
658
|
+
value.payment,
|
|
659
|
+
value.paymentRequired,
|
|
660
|
+
value.payment_required,
|
|
661
|
+
value.challenge,
|
|
662
|
+
value.x402,
|
|
663
|
+
]
|
|
664
|
+
|
|
665
|
+
for (const candidate of candidates) {
|
|
666
|
+
if (!candidate || typeof candidate !== 'object') continue
|
|
667
|
+
const nested = bodyChallengeJson(candidate)
|
|
668
|
+
if (nested) return nested
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return null
|
|
672
|
+
}
|
|
673
|
+
|
|
651
674
|
async function fetchDocument(url) {
|
|
652
675
|
const response = await fetch(url, {
|
|
653
676
|
headers: {
|
|
@@ -750,7 +773,11 @@ async function probeEndpoint(entry, origin) {
|
|
|
750
773
|
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
|
|
751
774
|
?? parseX402Authenticate(response.headers.get('www-authenticate'))
|
|
752
775
|
|
|
753
|
-
const
|
|
776
|
+
const bodyChallenge = bodyChallengeJson(body.json)
|
|
777
|
+
const bodyHasChallenge = Boolean(bodyChallenge)
|
|
778
|
+
if (bodyChallenge && body.json !== bodyChallenge) {
|
|
779
|
+
body.json = bodyChallenge
|
|
780
|
+
}
|
|
754
781
|
if (!bodyHasChallenge) {
|
|
755
782
|
if (headerChallenge && typeof headerChallenge === 'object') {
|
|
756
783
|
body.json = headerChallenge
|
|
@@ -1024,6 +1051,68 @@ function publicUrlCredentialFindings(value, path = 'document', depth = 0) {
|
|
|
1024
1051
|
return []
|
|
1025
1052
|
}
|
|
1026
1053
|
|
|
1054
|
+
const sensitiveQueryParamPattern =
|
|
1055
|
+
/^(?:account|account[_-]?id|address|birth[_-]?date|customer|customer[_-]?id|dob|email|e[_-]?mail|full[_-]?name|message|name|phone|postal|prompt|reason|session|session[_-]?id|ssn|tax[_-]?id|user|user[_-]?id|zip)$/i
|
|
1056
|
+
|
|
1057
|
+
function redactedSensitiveMetadataUrl(value) {
|
|
1058
|
+
if (!/^https?:\/\//i.test(String(value ?? ''))) return null
|
|
1059
|
+
try {
|
|
1060
|
+
const url = new URL(value)
|
|
1061
|
+
let changed = false
|
|
1062
|
+
|
|
1063
|
+
for (const [name] of Array.from(url.searchParams.entries())) {
|
|
1064
|
+
if (sensitiveQueryParamPattern.test(name)) {
|
|
1065
|
+
url.searchParams.set(name, 'REDACTED')
|
|
1066
|
+
changed = true
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return changed ? url.toString() : null
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
return null
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function looksLikeSensitiveMetadataText(value) {
|
|
1078
|
+
const text = String(value ?? '')
|
|
1079
|
+
if (!text) return ''
|
|
1080
|
+
if (/\b\d{3}-\d{2}-\d{4}\b/.test(text)) return 'SSN-like value'
|
|
1081
|
+
if (/\b[A-Z0-9._%+-]+@(?!example\.com\b)(?!example\.org\b)(?!example\.net\b)[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(text)) return 'email address'
|
|
1082
|
+
if (/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/.test(text)) return 'JWT-like token'
|
|
1083
|
+
if (/\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,})\b/.test(text)) return 'API token-like value'
|
|
1084
|
+
if (/\b(?:prompt|user message|customer note|private context)\s*[:=]\s*\S+/i.test(text)) return 'prompt or private-context text'
|
|
1085
|
+
return ''
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function metadataPrivacyFindings(value, path = 'challenge', depth = 0) {
|
|
1089
|
+
if (depth > 8 || value === null || value === undefined) return []
|
|
1090
|
+
if (typeof value === 'string') {
|
|
1091
|
+
const credentialUrl = redactedCredentialUrl(value)
|
|
1092
|
+
if (credentialUrl) {
|
|
1093
|
+
return [`P2 - Payment metadata exposes credential-like URL material at ${path}: ${credentialUrl}. Move provider tokens, signatures, sessions, or API keys out of payment-visible metadata.`]
|
|
1094
|
+
}
|
|
1095
|
+
const sensitiveUrl = redactedSensitiveMetadataUrl(value)
|
|
1096
|
+
if (sensitiveUrl) {
|
|
1097
|
+
return [`P2 - Payment metadata exposes sensitive query context at ${path}: ${sensitiveUrl}. Keep resource URLs coarse and move private user context into server-side records.`]
|
|
1098
|
+
}
|
|
1099
|
+
const sensitiveKind = looksLikeSensitiveMetadataText(value)
|
|
1100
|
+
return sensitiveKind
|
|
1101
|
+
? [`P2 - Payment metadata includes ${sensitiveKind} at ${path}. Redact or replace private context before it reaches facilitators, providers, logs, or receipts.`]
|
|
1102
|
+
: []
|
|
1103
|
+
}
|
|
1104
|
+
if (Array.isArray(value)) {
|
|
1105
|
+
return value.flatMap((item, index) => metadataPrivacyFindings(item, `${path}[${index}]`, depth + 1))
|
|
1106
|
+
}
|
|
1107
|
+
if (typeof value === 'object') {
|
|
1108
|
+
return Object.entries(value).flatMap(([key, item]) => {
|
|
1109
|
+
const safeKey = /^[a-zA-Z_$][\w$-]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`
|
|
1110
|
+
return metadataPrivacyFindings(item, `${path}${safeKey}`, depth + 1)
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
return []
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1027
1116
|
function cachePolicy(headers = {}) {
|
|
1028
1117
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
1029
1118
|
}
|
|
@@ -1195,11 +1284,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1195
1284
|
}
|
|
1196
1285
|
}
|
|
1197
1286
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
1198
|
-
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
1287
|
+
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${displayPaymentMetadataValue(summary.resourceUrl || summary.extraResource)}.`)
|
|
1199
1288
|
}
|
|
1200
1289
|
if (looksLikeLocalResourceUrl(summary.resourceUrl) || looksLikeLocalResourceUrl(summary.extraResource)) {
|
|
1201
|
-
findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${summary.resourceUrl || summary.extraResource}.`)
|
|
1290
|
+
findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${displayPaymentMetadataValue(summary.resourceUrl || summary.extraResource)}.`)
|
|
1202
1291
|
}
|
|
1292
|
+
findings.push(...metadataPrivacyFindings(result.body.json, `${result.name} challenge`))
|
|
1203
1293
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
1204
1294
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
1205
1295
|
}
|
|
@@ -1331,6 +1421,9 @@ function groupedFindingLabel(finding) {
|
|
|
1331
1421
|
if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
|
|
1332
1422
|
return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
|
|
1333
1423
|
}
|
|
1424
|
+
if (/Payment metadata exposes|Payment metadata includes/.test(finding)) {
|
|
1425
|
+
return 'P2 - Payment metadata exposes private or sensitive context.'
|
|
1426
|
+
}
|
|
1334
1427
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1335
1428
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1336
1429
|
}
|
|
@@ -1375,13 +1468,21 @@ function referenceGuides(findings) {
|
|
|
1375
1468
|
add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
|
|
1376
1469
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1377
1470
|
}
|
|
1378
|
-
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1471
|
+
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs|Payment metadata exposes|Payment metadata includes|private user context|facilitators, providers, logs, or receipts/i.test(text)) {
|
|
1379
1472
|
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1380
1473
|
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
1381
1474
|
}
|
|
1382
1475
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
1383
1476
|
}
|
|
1384
1477
|
|
|
1478
|
+
function displayPaymentMetadataValue(value) {
|
|
1479
|
+
if (value === null || value === undefined || value === '') return '-'
|
|
1480
|
+
if (typeof value !== 'string') return displayMetadataValue(value)
|
|
1481
|
+
return redactedCredentialUrl(value)
|
|
1482
|
+
?? redactedSensitiveMetadataUrl(value)
|
|
1483
|
+
?? value
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1385
1486
|
function formatMarkdown(report) {
|
|
1386
1487
|
const document = report.document.body.json ?? {}
|
|
1387
1488
|
const documentType = report.directEndpoint
|
|
@@ -1389,7 +1490,7 @@ function formatMarkdown(report) {
|
|
|
1389
1490
|
: (report.mcpCatalog?.tools?.length ? 'Streamable HTTP MCP endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'))
|
|
1390
1491
|
const challengeRows = report.challenges.map(result => {
|
|
1391
1492
|
const summary = challengeSummary(result)
|
|
1392
|
-
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl
|
|
1493
|
+
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${displayPaymentMetadataValue(summary.resourceUrl)} |`
|
|
1393
1494
|
})
|
|
1394
1495
|
const preflightRows = report.preflights.map(result => {
|
|
1395
1496
|
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${result.headers['access-control-allow-origin'] ?? '-'} | ${result.headers['access-control-allow-headers'] ?? '-'} | ${result.headers['access-control-allow-methods'] ?? '-'} |`
|