x402-surface-check 0.2.37 → 0.2.39
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 -1
- package/bin/x402-surface-check.mjs +119 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ npx --yes x402-surface-check --strict-proof https://api.example.com/openapi.json
|
|
|
20
20
|
|
|
21
21
|
## What It Checks
|
|
22
22
|
|
|
23
|
-
- 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
|
|
23
|
+
- Manifest endpoint discovery from `items[]`, `endpoints[]`, marketplace `skills[]` / `catalog.skills[]`, object-valued `endpoints`, string-valued endpoint maps, `tools` maps, `resources[]`, `x402Endpoints`, category arrays, raw resource URL strings, method-prefixed resource strings, and OpenAPI paths
|
|
24
24
|
- Streamable HTTP MCP tool catalogs via safe JSON-RPC `tools/list` probes with `Accept: application/json, text/event-stream`
|
|
25
25
|
- 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
|
|
26
26
|
- Linked discovery documents via `discovery_url`, `discoveryUrl`, `resources_url`, `resourcesUrl`, string `discovery` links, nested `discovery.x402_json` / OpenAPI links, or manifest-level OpenAPI links
|
|
@@ -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
|
|
@@ -358,6 +358,24 @@ function manifestEndpointBody(endpoint, document) {
|
|
|
358
358
|
return exampleValue(body, document)
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
function marketplaceSkillBody(skill) {
|
|
362
|
+
return skill?.example?.input
|
|
363
|
+
?? skill?.exampleInput
|
|
364
|
+
?? skill?.input?.example
|
|
365
|
+
?? skill?.input?.safe_example
|
|
366
|
+
?? skill?.input?.safeExample
|
|
367
|
+
?? manifestEndpointBody(skill, {})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function marketplaceSkillPriceUsd(skill) {
|
|
371
|
+
const value = skill?.price?.amount
|
|
372
|
+
?? skill?.price?.usd
|
|
373
|
+
?? skill?.priceUsd
|
|
374
|
+
?? skill?.price_usd
|
|
375
|
+
?? skill?.pricePerCall
|
|
376
|
+
return numberFromDecimal(value)
|
|
377
|
+
}
|
|
378
|
+
|
|
361
379
|
function manifestEndpointUrl(rawPath, endpoint, baseUrl, sourceUrl) {
|
|
362
380
|
const url = new URL(endpointUrl(rawPath, baseUrl, sourceUrl))
|
|
363
381
|
const parameters = endpoint?.parameters
|
|
@@ -404,6 +422,29 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
404
422
|
}
|
|
405
423
|
}
|
|
406
424
|
|
|
425
|
+
const marketplaceSkillLists = [
|
|
426
|
+
document.skills,
|
|
427
|
+
document.services,
|
|
428
|
+
document.catalog?.skills,
|
|
429
|
+
document.catalog?.services,
|
|
430
|
+
].filter(Array.isArray)
|
|
431
|
+
|
|
432
|
+
for (const skillList of marketplaceSkillLists) {
|
|
433
|
+
for (const skill of skillList) {
|
|
434
|
+
if (!skill || typeof skill !== 'object') continue
|
|
435
|
+
const rawPath = skill.endpoint ?? skill.url ?? skill.path
|
|
436
|
+
if (!rawPath) continue
|
|
437
|
+
if (redactedCredentialUrl(rawPath)) continue
|
|
438
|
+
entries.push({
|
|
439
|
+
name: skill.slug ?? skill.id ?? skill.name ?? String(rawPath).split('/').filter(Boolean).at(-1) ?? String(rawPath),
|
|
440
|
+
url: endpointUrl(rawPath, baseUrl, sourceUrl),
|
|
441
|
+
method: String(skill.method ?? 'POST').toUpperCase(),
|
|
442
|
+
expectedPriceUsd: marketplaceSkillPriceUsd(skill),
|
|
443
|
+
requestBody: marketplaceSkillBody(skill),
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
407
448
|
if (Array.isArray(document.endpoints)) {
|
|
408
449
|
for (const endpoint of document.endpoints) {
|
|
409
450
|
const rawPath = endpoint?.url ?? endpoint?.endpoint ?? endpoint?.path
|
|
@@ -1051,6 +1092,68 @@ function publicUrlCredentialFindings(value, path = 'document', depth = 0) {
|
|
|
1051
1092
|
return []
|
|
1052
1093
|
}
|
|
1053
1094
|
|
|
1095
|
+
const sensitiveQueryParamPattern =
|
|
1096
|
+
/^(?: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
|
|
1097
|
+
|
|
1098
|
+
function redactedSensitiveMetadataUrl(value) {
|
|
1099
|
+
if (!/^https?:\/\//i.test(String(value ?? ''))) return null
|
|
1100
|
+
try {
|
|
1101
|
+
const url = new URL(value)
|
|
1102
|
+
let changed = false
|
|
1103
|
+
|
|
1104
|
+
for (const [name] of Array.from(url.searchParams.entries())) {
|
|
1105
|
+
if (sensitiveQueryParamPattern.test(name)) {
|
|
1106
|
+
url.searchParams.set(name, 'REDACTED')
|
|
1107
|
+
changed = true
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return changed ? url.toString() : null
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
return null
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function looksLikeSensitiveMetadataText(value) {
|
|
1119
|
+
const text = String(value ?? '')
|
|
1120
|
+
if (!text) return ''
|
|
1121
|
+
if (/\b\d{3}-\d{2}-\d{4}\b/.test(text)) return 'SSN-like value'
|
|
1122
|
+
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'
|
|
1123
|
+
if (/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/.test(text)) return 'JWT-like token'
|
|
1124
|
+
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'
|
|
1125
|
+
if (/\b(?:prompt|user message|customer note|private context)\s*[:=]\s*\S+/i.test(text)) return 'prompt or private-context text'
|
|
1126
|
+
return ''
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function metadataPrivacyFindings(value, path = 'challenge', depth = 0) {
|
|
1130
|
+
if (depth > 8 || value === null || value === undefined) return []
|
|
1131
|
+
if (typeof value === 'string') {
|
|
1132
|
+
const credentialUrl = redactedCredentialUrl(value)
|
|
1133
|
+
if (credentialUrl) {
|
|
1134
|
+
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.`]
|
|
1135
|
+
}
|
|
1136
|
+
const sensitiveUrl = redactedSensitiveMetadataUrl(value)
|
|
1137
|
+
if (sensitiveUrl) {
|
|
1138
|
+
return [`P2 - Payment metadata exposes sensitive query context at ${path}: ${sensitiveUrl}. Keep resource URLs coarse and move private user context into server-side records.`]
|
|
1139
|
+
}
|
|
1140
|
+
const sensitiveKind = looksLikeSensitiveMetadataText(value)
|
|
1141
|
+
return sensitiveKind
|
|
1142
|
+
? [`P2 - Payment metadata includes ${sensitiveKind} at ${path}. Redact or replace private context before it reaches facilitators, providers, logs, or receipts.`]
|
|
1143
|
+
: []
|
|
1144
|
+
}
|
|
1145
|
+
if (Array.isArray(value)) {
|
|
1146
|
+
return value.flatMap((item, index) => metadataPrivacyFindings(item, `${path}[${index}]`, depth + 1))
|
|
1147
|
+
}
|
|
1148
|
+
if (typeof value === 'object') {
|
|
1149
|
+
return Object.entries(value).flatMap(([key, item]) => {
|
|
1150
|
+
const safeKey = /^[a-zA-Z_$][\w$-]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`
|
|
1151
|
+
return metadataPrivacyFindings(item, `${path}${safeKey}`, depth + 1)
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
return []
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1054
1157
|
function cachePolicy(headers = {}) {
|
|
1055
1158
|
return headers['cache-control'] ?? headers.cacheControl ?? ''
|
|
1056
1159
|
}
|
|
@@ -1222,11 +1325,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
1222
1325
|
}
|
|
1223
1326
|
}
|
|
1224
1327
|
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}.`)
|
|
1328
|
+
findings.push(`P1 - ${result.name} challenge uses a non-HTTPS resource URL: ${displayPaymentMetadataValue(summary.resourceUrl || summary.extraResource)}.`)
|
|
1226
1329
|
}
|
|
1227
1330
|
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}.`)
|
|
1331
|
+
findings.push(`P1 - ${result.name} challenge binds payment to a localhost/private-development resource URL: ${displayPaymentMetadataValue(summary.resourceUrl || summary.extraResource)}.`)
|
|
1229
1332
|
}
|
|
1333
|
+
findings.push(...metadataPrivacyFindings(result.body.json, `${result.name} challenge`))
|
|
1230
1334
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
1231
1335
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
1232
1336
|
}
|
|
@@ -1358,6 +1462,9 @@ function groupedFindingLabel(finding) {
|
|
|
1358
1462
|
if (/does not advertise signed offer\/receipt metadata/.test(finding)) {
|
|
1359
1463
|
return 'P3 - Payment challenges do not advertise signed offer/receipt metadata.'
|
|
1360
1464
|
}
|
|
1465
|
+
if (/Payment metadata exposes|Payment metadata includes/.test(finding)) {
|
|
1466
|
+
return 'P2 - Payment metadata exposes private or sensitive context.'
|
|
1467
|
+
}
|
|
1361
1468
|
if (/content while payment headers advertise enforcement/.test(finding)) {
|
|
1362
1469
|
return 'P2 - Payment headers advertise enforcement on a 200 response.'
|
|
1363
1470
|
}
|
|
@@ -1402,13 +1509,21 @@ function referenceGuides(findings) {
|
|
|
1402
1509
|
add('x402 Signed Offers & Receipts', 'https://docs.x402.org/extensions/offer-receipt')
|
|
1403
1510
|
add('x402 Attack Map 2026', 'https://tateprograms.com/x402-attack-map-2026.html')
|
|
1404
1511
|
}
|
|
1405
|
-
if (/credential-like URL material|provider tokens|API keys|registry-visible endpoint URLs/i.test(text)) {
|
|
1512
|
+
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
1513
|
add('x402 Metadata Filter', 'https://tateprograms.com/x402-metadata-filter.html')
|
|
1407
1514
|
add('Agent Commerce Gate', 'https://tateprograms.com/agent-commerce-gate.html')
|
|
1408
1515
|
}
|
|
1409
1516
|
return guides.map(guide => `- ${guide.label}: ${guide.url}`)
|
|
1410
1517
|
}
|
|
1411
1518
|
|
|
1519
|
+
function displayPaymentMetadataValue(value) {
|
|
1520
|
+
if (value === null || value === undefined || value === '') return '-'
|
|
1521
|
+
if (typeof value !== 'string') return displayMetadataValue(value)
|
|
1522
|
+
return redactedCredentialUrl(value)
|
|
1523
|
+
?? redactedSensitiveMetadataUrl(value)
|
|
1524
|
+
?? value
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1412
1527
|
function formatMarkdown(report) {
|
|
1413
1528
|
const document = report.document.body.json ?? {}
|
|
1414
1529
|
const documentType = report.directEndpoint
|
|
@@ -1416,7 +1531,7 @@ function formatMarkdown(report) {
|
|
|
1416
1531
|
: (report.mcpCatalog?.tools?.length ? 'Streamable HTTP MCP endpoint' : (document.openapi ? 'OpenAPI' : 'x402 manifest or JSON document'))
|
|
1417
1532
|
const challengeRows = report.challenges.map(result => {
|
|
1418
1533
|
const summary = challengeSummary(result)
|
|
1419
|
-
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${summary.resourceUrl
|
|
1534
|
+
return `| ${result.name} | ${result.method ?? 'POST'} | ${result.status} | ${summary.protocol || '-'} | ${summary.price || '-'} | ${summary.network || '-'} | ${displayPaymentMetadataValue(summary.resourceUrl)} |`
|
|
1420
1535
|
})
|
|
1421
1536
|
const preflightRows = report.preflights.map(result => {
|
|
1422
1537
|
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'] ?? '-'} |`
|