x402-surface-check 0.2.37 → 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 +1 -0
- package/bin/x402-surface-check.mjs +78 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
40
40
|
- HTTPS resource URLs and stable resource metadata
|
|
41
41
|
- Resource binding across top-level `resource.url` and every accept leg, including localhost/private-development resource URLs that should not ship in production
|
|
42
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
|
|
43
44
|
- Browser CORS allowance for the requesting origin, common x402/MPP retry headers, and exposed challenge/session headers on the actual 402 response
|
|
44
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
|
|
45
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
|
|
@@ -1051,6 +1051,68 @@ function publicUrlCredentialFindings(value, path = 'document', depth = 0) {
|
|
|
1051
1051
|
return []
|
|
1052
1052
|
}
|
|
1053
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
|
+
|
|
1054
1116
|
function cachePolicy(headers = {}) {
|
|
1055
1117
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
1056
1118
|
}
|
|
@@ -1222,11 +1284,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1222
1284
|
}
|
|
1223
1285
|
}
|
|
1224
1286
|
if (summary.resourceUrl.startsWith('http://') || summary.extraResource.startsWith('http://')) {
|
|
1225
|
-
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)}.`)
|
|
1226
1288
|
}
|
|
1227
1289
|
if (looksLikeLocalResourceUrl(summary.resourceUrl) || looksLikeLocalResourceUrl(summary.extraResource)) {
|
|
1228
|
-
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)}.`)
|
|
1229
1291
|
}
|
|
1292
|
+
findings.push(...metadataPrivacyFindings(result.body.json, `${result.name} challenge`))
|
|
1230
1293
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
1231
1294
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
1232
1295
|
}
|
|
@@ -1358,6 +1421,9 @@ function groupedFindingLabel(finding) {
|
|
|
1358
1421
|
if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
|
|
1359
1422
|
return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
|
|
1360
1423
|
}
|
|
1424
|
+
if (/Payment metadata exposes|Payment metadata includes/.test(finding)) {
|
|
1425
|
+
return 'P2 - Payment metadata exposes private or sensitive context.'
|
|
1426
|
+
}
|
|
1361
1427
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1362
1428
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1363
1429
|
}
|
|
@@ -1402,13 +1468,21 @@ function referenceGuides(findings) {
|
|
|
1402
1468
|
add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
|
|
1403
1469
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1404
1470
|
}
|
|
1405
|
-
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)) {
|
|
1406
1472
|
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1407
1473
|
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
1408
1474
|
}
|
|
1409
1475
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
1410
1476
|
}
|
|
1411
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
|
+
|
|
1412
1486
|
function formatMarkdown(report) {
|
|
1413
1487
|
const document = report.document.body.json ?? {}
|
|
1414
1488
|
const documentType = report.directEndpoint
|
|
@@ -1416,7 +1490,7 @@ function formatMarkdown(report) {
|
|
|
1416
1490
|
: (report.mcpCatalog?.tools?.length ? 'Streamable HTTP MCP endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'))
|
|
1417
1491
|
const challengeRows = report.challenges.map(result => {
|
|
1418
1492
|
const summary = challengeSummary(result)
|
|
1419
|
-
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)} |`
|
|
1420
1494
|
})
|
|
1421
1495
|
const preflightRows = report.preflights.map(result => {
|
|
1422
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'] ?? '-'} |`
|