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 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 bodyHasChallenge = Array.isArray(body.json?.accepts) || Array.isArray(body.json?.schemes)
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'] ?? '-'} |`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-surface-check",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
5
5
  "type": "module",
6
6
  "bin": {