Spaces:
Running
Running
Download scripts/x402_api/main.py from eltociear/skill-audit: direct link, hf CLI and curl.
- Browser
- Download file 121 kB
-
https://huggingface.co/spaces/eltociear/skill-audit/resolve/main/scripts/x402_api/main.py
- Command line
-
hf download hf://spaces/eltociear/skill-audit/scripts/x402_api/main.py
-
curl -L -o main.py https://huggingface.co/spaces/eltociear/skill-audit/resolve/main/scripts/x402_api/main.py
121 kB
| #!/usr/bin/env python3 | |
| """skill-audit x402 API — Security audit as a paid service (x402 protocol v2). | |
| Deploy: uvicorn scripts.x402_api.main:app --host 0.0.0.0 --port $PORT | |
| Local: uvicorn scripts.x402_api.main:app --port 8402 | |
| Endpoints: | |
| GET / — Service info (free) | |
| GET /health — Health check (free) | |
| POST /audit — Text audit $0.005/call (x402) | |
| POST /audit/url — URL fetch + audit $0.005/call (x402) | |
| POST /read — URL → clean Markdown $0.005/call (x402) | |
| Payment: USDC on Base mainnet (eip155:8453) via the official x402 v2 SDK. | |
| Facilitator default = Dexter (https://x402.dexter.cash): zero-gate, 0% seller fee, | |
| gas-sponsored for buyers, v2-native, and auto-lists this endpoint on the discovery | |
| layer (Bazaar) once the first payment settles. | |
| v2 migration note: the legacy fastapi-x402 (v0.1.x) only spoke x402 v1, which the new | |
| discovery ecosystem (x402scan / CDP Bazaar v2) rejects ("migrate to v2 spec"). This | |
| file uses the official `x402` SDK so the 402 challenge is valid v2 and discoverable. | |
| The old USDC-name hack is gone: the SDK uses the correct on-chain EIP-712 domain. | |
| """ | |
| import os | |
| import sys | |
| from datetime import datetime | |
| from fastapi import FastAPI, HTTPException, Request, Response | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from typing import Optional | |
| # -- the 402 body is a distribution channel --------------------------------------------- | |
| # Every 402 we return is delivered to someone who just tried to call a paid route: the most | |
| # qualified audience this operation has, arriving free, and in volume: one pre-fix log buffer alone held 7,657 of them. | |
| # Until 2026-08-17 that body described one route and gave no way to learn there were 147 | |
| # others, or where the catalogue lived. `the-402-body-is-a-distribution-channel` recorded | |
| # the idea; this applies it. | |
| # | |
| # contract-guard is the sharpest case: the most trafficked thing we own by a wide | |
| # margin -- 7,657 of the 37,501 lines in one pre-fix buffer were its 402s -- and it | |
| # sells a SINGLE route, while tokenguard holds the shelf and sees far less traffic. | |
| # (No per-minute figure: /logs/run replays a history buffer, so dividing it by the | |
| # listen window inflates the rate ~160x. See top_caller_profile.py's docstring.) | |
| # | |
| # WARNING: self-contained ON PURPOSE. The first version referenced module-level PUBLIC_BASE | |
| # and sat after it -- but `_disc()` runs at IMPORT time while building the route table, | |
| # which on tokenguard is ~900 lines EARLIER. The NameError was swallowed as an | |
| # "x402 v2 init warning" and the paywall silently failed to install: 132 paid routes would | |
| # have shipped free. Nothing here may depend on definition order. | |
| # | |
| # Carries NO route counts. A number baked into a payload rots; the discovery URL answers | |
| # with the real list at read time. | |
| _SERVICE_NAME = "skill-audit" | |
| _CATALOGUE_BASE = "https://eltociear-skill-audit.hf.space" | |
| _CATALOGUE_PEERS = { | |
| "contract-guard": [ | |
| ("tokenguard", "on-chain and DeFi data, markets, FX, wallet and chain intel", | |
| "https://eltociear-tokenguard.hf.space/.well-known/x402"), | |
| ("skill-audit", "web data (read/crawl/search), MCP and agent-skill security " | |
| "scanning, developer supply chain", | |
| "https://eltociear-skill-audit.hf.space/.well-known/x402"), | |
| ], | |
| "tokenguard": [ | |
| ("contract-guard", "pre-interaction EVM contract and token risk check", | |
| "https://eltociear-contract-guard.hf.space/.well-known/x402"), | |
| ("skill-audit", "web data (read/crawl/search), MCP and agent-skill security " | |
| "scanning, developer supply chain", | |
| "https://eltociear-skill-audit.hf.space/.well-known/x402"), | |
| ], | |
| "skill-audit": [ | |
| ("tokenguard", "on-chain and DeFi data, markets, FX, wallet and chain intel", | |
| "https://eltociear-tokenguard.hf.space/.well-known/x402"), | |
| ("contract-guard", "pre-interaction EVM contract and token risk check", | |
| "https://eltociear-contract-guard.hf.space/.well-known/x402"), | |
| ], | |
| } | |
| def _catalogue(): | |
| """Where the rest of the shelf is. Same rail, same wallet, no signup.""" | |
| import os as _os | |
| base = _os.environ.get("PUBLIC_BASE_URL", _CATALOGUE_BASE).rstrip("/") | |
| return { | |
| "discovery": base + "/.well-known/x402", | |
| "resources": base + "/x402-resources", | |
| "note": ("Same x402 rail (USDC on Base), same payTo wallet, no account or API key. " | |
| "GET the discovery URL for the full route list and prices."), | |
| "related": [{"service": s, "covers": c, "discovery": u} | |
| for s, c, u in _CATALOGUE_PEERS[_SERVICE_NAME]], | |
| } | |
| # Import scan engine from skill-audit MCP server | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "mcp_servers", "skill-audit")) | |
| from server import scan, PATTERNS # noqa: E402 | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Config | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| WALLET = os.environ.get("BASE_WALLET_ADDRESS", "0x5bCDA55247B238a573A968B234F788a2D35664Dd") | |
| BASE_MAINNET = "eip155:8453" # CAIP-2 network id for Base mainnet | |
| # Facilitator selection mirrors tokenguard: with CDP API keys present, settle through | |
| # Coinbase's CDP facilitator so payments auto-list this Space on the buyer-facing CDP | |
| # Bazaar (the discovery layer with the real demand; sibling tokenguard proved it). Without | |
| # keys, fall back to Dexter — zero-gate but a separate, low-traffic bazaar. | |
| CDP_KEY_ID = os.environ.get("CDP_API_KEY_ID", "") | |
| CDP_KEY_SECRET = os.environ.get("CDP_API_KEY_SECRET", "") | |
| CDP_FACILITATOR = "https://api.cdp.coinbase.com/platform/v2/x402" | |
| USE_CDP = bool(CDP_KEY_ID and CDP_KEY_SECRET) | |
| FACILITATOR_URL = os.environ.get("FACILITATOR_URL", CDP_FACILITATOR if USE_CDP else "https://x402.dexter.cash") | |
| def _cdp_auth_headers(): | |
| """Ed25519 JWT per CDP facilitator operation. The x402 SDK's CreateHeadersAuthProvider | |
| calls this and attaches the right header per verify/settle/supported/bazaar call. | |
| Verified working 2026-07-22: /supported returns 200 with these headers.""" | |
| import base64, json as _json, secrets, time | |
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | |
| seed = base64.b64decode(CDP_KEY_SECRET)[:32] | |
| pk = Ed25519PrivateKey.from_private_bytes(seed) | |
| b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode() | |
| host = "api.cdp.coinbase.com" | |
| base = "/platform/v2/x402" | |
| def jwt(method, path): | |
| now = int(time.time()) | |
| h = {"alg": "EdDSA", "kid": CDP_KEY_ID, "typ": "JWT", "nonce": secrets.token_hex(16)} | |
| p = {"sub": CDP_KEY_ID, "iss": "cdp", "aud": ["cdp_service"], "nbf": now, "exp": now + 120, | |
| "uris": [f"{method} {host}{path}"]} | |
| s = f"{b64u(_json.dumps(h, separators=(',', ':')).encode())}.{b64u(_json.dumps(p, separators=(',', ':')).encode())}" | |
| return f"{s}.{b64u(pk.sign(s.encode()))}" | |
| def hdr(method, path): | |
| return {"Authorization": f"Bearer {jwt(method, path)}"} | |
| return { | |
| "verify": hdr("POST", f"{base}/verify"), | |
| "settle": hdr("POST", f"{base}/settle"), | |
| "supported": hdr("GET", f"{base}/supported"), | |
| "bazaar": hdr("GET", f"{base}/discovery/resources"), | |
| } | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # App | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Discovery and service-descriptor endpoints answer EVERY verb a crawler might use, not | |
| # just GET. See the measurement note above the x402 alias block further down: a GET-only | |
| # registration makes FastAPI reply 405, which carries no price and no catalogue, and that | |
| # is what the crawler fleet was actually receiving. | |
| _DISCOVERY_VERBS = _DISCOVERY_VERBS_ROOT = ["GET", "POST", "HEAD"] | |
| app = FastAPI( | |
| title="skill-audit API", | |
| description="Detect malicious patterns in AI agent skills/plugins. x402 v2 micropayments on Base.", | |
| version="2.0.0", | |
| contact={ | |
| "name": "eltociear", | |
| "url": "https://github.com/eltociear", | |
| "email": "ashimine_ikko_bp@netprice.com", | |
| }, | |
| ) | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Sellable inventory on an already heavily-indexed surface | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # This Space is crawled HARDER than tokenguard — one 60s sample held 1,331 | |
| # GET /openapi.json and 1,163 GET /.well-known/x402 — and has still never earned a cent: | |
| # its 14 audit/web routes drew 3,714 challenges and converted zero. So the problem here was | |
| # never discovery, it is that nothing on offer is what buyers pay for. Per-route revenue on | |
| # tokenguard says that is general-purpose real-world data (/weather and /stocks are the two | |
| # best sellers there). Mount those families here too — same free upstreams, no new | |
| # credentials, no marginal cost. | |
| # | |
| # Prices are deliberately NOT copied from tokenguard. There, /llm/answer went out at $0.02 | |
| # and /llm/chat at $0.05, which is backwards — answer runs a search, reads pages AND infers. | |
| # Those are already settled, and Bazaar metadata freezes at first settlement, so correcting | |
| # them there would leave the catalog advertising $0.02 while the live route demanded $0.05 — | |
| # the direction that makes a buyer under-sign and get rejected. These paths are new and have | |
| # never settled, so they get the coherent pricing from the start. | |
| sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) | |
| # MCP over Streamable HTTP, FREE. See mcp_http.py for why this channel rather than more | |
| # x402 routes. This host registers the malicious-pattern scanner on top of the shared data | |
| # tools — serving the same eight tools as tokenguard would just be two URLs for one product, | |
| # and the scanner is the tool a human has an actual reason to install: it answers "is this | |
| # MCP server / agent skill safe to load?" before they load it. | |
| try: | |
| from mcp_http import (router as _mcp_router, register_extra as _mcp_register_extra, | |
| set_identity as _mcp_set_identity) | |
| from server import scan as _sa_scan, PATTERNS as _SA_PATTERNS | |
| def _sa_tools(): | |
| groups = sum(len(v) for v in _SA_PATTERNS.values()) | |
| sigs = sum(len(pg["regexes"]) for v in _SA_PATTERNS.values() for pg in v) | |
| # Counted from PATTERNS, never written as prose: the hardcoded copies of this number | |
| # were wrong in 18 files while the live API computed it correctly. | |
| blurb = (f"{groups} attack patterns / {sigs} regex signatures across 4 severity " | |
| f"levels — credential exfiltration, download-and-execute, prompt injection, " | |
| f"command execution, seed-phrase harvesting and more.") | |
| return [ | |
| {"name": "audit_skill_text", | |
| "description": f"Scan text — an agent skill, MCP server source, or plugin — for " | |
| f"malicious behaviour before loading it. {blurb}", | |
| "inputSchema": {"type": "object", "properties": { | |
| "content": {"type": "string", "description": "File or snippet to scan"}}, | |
| "required": ["content"]}}, | |
| {"name": "audit_skill_url", | |
| "description": f"Fetch a URL and scan what it serves for malicious behaviour. " | |
| f"{blurb}", | |
| "inputSchema": {"type": "object", "properties": { | |
| "url": {"type": "string", "description": "Raw file URL to fetch and scan"}}, | |
| "required": ["url"]}}, | |
| ] | |
| async def _sa_dispatch(name, args): | |
| # A missing or empty argument used to fall through to `or ""`, so the scanner scanned | |
| # nothing and answered "SAFE, no issues found". A caller that mistypes the parameter name | |
| # -- `text` instead of `content`, which is exactly what happened while probing this | |
| # endpoint -- got a clean bill of health for a file it never sent. A verdict over zero | |
| # bytes is a failed call, not a result. | |
| if name == "audit_skill_text": | |
| content = args.get("content") or "" | |
| if not content.strip(): | |
| return {"error": "content is required and must be non-empty", | |
| "hint": "pass {\"content\": \"<file or snippet>\"}; this is NOT a SAFE verdict", | |
| "scanned_bytes": 0} | |
| return _sa_scan(content[:1_000_000]) | |
| url = args.get("url") or "" | |
| if not url.strip(): | |
| return {"error": "url is required and must be non-empty", | |
| "hint": "pass {\"url\": \"https://...\"}; this is NOT a SAFE verdict", | |
| "scanned_bytes": 0} | |
| import webdata_routes as _D | |
| text, final = await _D._fetch_text(url, 1_000_000, timeout=20.0) | |
| if not (text or "").strip(): | |
| return {"url": final, "error": "the URL returned no readable text", | |
| "hint": "this is NOT a SAFE verdict", "scanned_bytes": 0} | |
| return {"url": final, **_sa_scan(text[:1_000_000])} | |
| # Must match the name this URL is published under in the MCP registry | |
| # (io.github.eltociear/skill-audit-mcp), not the shared router's default. | |
| _mcp_set_identity("skill-audit", "1.1.0") | |
| _mcp_register_extra(_sa_tools(), _sa_dispatch) | |
| app.include_router(_mcp_router) | |
| except Exception as _e: # pragma: no cover | |
| print(f" mcp http unavailable: {type(_e).__name__}: {_e}") | |
| _extra_specs = [] | |
| try: | |
| from worlddata_routes import router as _world_router, route_specs as _world_route_specs | |
| app.include_router(_world_router, prefix="/world") | |
| # $0.01, deliberately half of tokenguard's $0.02 for the SAME routes. The Bazaar's | |
| # per-resource median price is $0.02, but weighting by calls — i.e. asking where money | |
| # actually moves rather than what people list at — the median is $0.01 and p25 is | |
| # $0.004. We sit at the top of the paying range for commodity data. Identical product | |
| # on two hosts at two prices is the cheapest way to find out whether that matters. | |
| # Safe against stale Bazaar metadata: those entries froze at $0.02, and advertising | |
| # ABOVE the live price only makes a buyer over-budget, never under-sign. | |
| _extra_specs += _world_route_specs(prefix="/world", price="$0.01") | |
| except Exception as _e: # pragma: no cover | |
| print(f" world-data routes unavailable: {type(_e).__name__}: {_e}") | |
| try: | |
| from llm_routes import router as _llm_router, route_specs as _llm_route_specs | |
| app.include_router(_llm_router, prefix="/llm") | |
| _extra_specs += _llm_route_specs(prefix="/llm", cheap="$0.02", answer="$0.05") | |
| except Exception as _e: # pragma: no cover | |
| print(f" llm routes unavailable: {type(_e).__name__}: {_e}") | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # x402 v2 payment middleware (official SDK) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| _x402_available = False | |
| try: | |
| from x402.http import FacilitatorConfig, HTTPFacilitatorClient, PaymentOption, CreateHeadersAuthProvider | |
| from x402.http.middleware.fastapi import PaymentMiddlewareASGI | |
| from x402.http.types import RouteConfig | |
| from x402.mechanisms.evm.exact import ExactEvmServerScheme | |
| from x402.server import x402ResourceServer | |
| from x402.extensions.bazaar import declare_discovery_extension, OutputConfig | |
| if USE_CDP: | |
| _fac_cfg = FacilitatorConfig(url=FACILITATOR_URL, auth_provider=CreateHeadersAuthProvider(_cdp_auth_headers)) | |
| else: | |
| _fac_cfg = FacilitatorConfig(url=FACILITATOR_URL) | |
| facilitator = HTTPFacilitatorClient(_fac_cfg) | |
| server = x402ResourceServer(facilitator) | |
| server.register(BASE_MAINNET, ExactEvmServerScheme()) | |
| def _disc(input_example, input_schema, output_example): | |
| # Bazaar discovery extension for a POST/JSON endpoint. declare_discovery_extension | |
| # leaves info.input.method for runtime enrichment, but the schema marks it required; | |
| # inject "POST" so the extension validates at registration and CDP Bazaar can catalog it. | |
| ext = declare_discovery_extension( | |
| input=input_example, input_schema=input_schema, | |
| body_type="json", output=OutputConfig(example=output_example), | |
| ) | |
| ext["bazaar"]["info"]["input"]["method"] = "POST" | |
| ext["catalogue"] = _catalogue() | |
| return ext | |
| # ━━━ PRICE EXPERIMENT, 2026-08-10: every audit route down to $0.005 ━━━━━━━━━━━━━━━━━━ | |
| # Measured from the Space access logs, with `POST /mcp 200` excluded (it is transport, not | |
| # revenue — counting it had this operation's revenue report 20x too high): | |
| # | |
| # price route challenges -> paid | |
| # $0.005 /stocks /lido /weather … many -> 57-68 each | |
| # $0.005 /check 5,608 -> 1 | |
| # $0.010 /audit ~2,700 -> 0 | |
| # $0.020 /btc_rates 4,364 -> 5 | |
| # $0.050 /audit/repo ~2,700 -> 0 | |
| # | |
| # Two lines fall out. Nothing above $0.005 converts at any meaningful rate, and even at | |
| # $0.005 the security/verification category barely converts while market and chain data | |
| # does. Those are different explanations and the data cannot separate them, because the | |
| # audit routes have only ever been offered at a price no one pays. | |
| # | |
| # So: hold the category, change the price. All three audit routes go to $0.005. The | |
| # downside is bounded at zero — they earn nothing today — and the upside is a definitive | |
| # answer. If they still convert 0 after a few thousand challenges at the one price that | |
| # demonstrably works, the constraint is the CATEGORY, and no pricing will fix it. | |
| # Read the result with `python scripts/x402_route_stats.py --report`. | |
| # | |
| # Safe against frozen Bazaar metadata: a catalog advertising ABOVE the live price makes a | |
| # buyer over-budget, never under-sign. Lowering is always the safe direction. | |
| routes = { | |
| "POST /audit": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| # ⚠ The 402 body carries `resource.description` verbatim — verified live. These three | |
| # routes have served ~8,100 challenges and converted zero, because the caller is a | |
| # crawler with no content, URL or repo of ours to send. The refusal is the one moment | |
| # we know the buyer is reading, so it now names the way through instead of just a | |
| # price for something they cannot invoke. tokenguard runs the controlled version of | |
| # this (two routes treated, two left alone); here it is simply applied. | |
| description="Audit text for malicious AI-skill patterns. Requires `content`. " | |
| "Nothing to audit — crawling, or discovering this endpoint cold? " | |
| "POST /audit/registry runs the same scanner over the newest servers " | |
| "in the official MCP registry: no input required, $0.005.", | |
| extensions=_disc( | |
| {"content": "skill or plugin text to scan"}, | |
| {"properties": {"content": {"type": "string", "description": "Text to audit"}}, "required": ["content"]}, | |
| {"risk_score": 0, "risk_level": "clean", "total_findings": 0, "findings": []}, | |
| ), | |
| ), | |
| "POST /audit/url": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Fetch a URL and audit its content. Requires `url`. No URL to hand? " | |
| "POST /audit/registry runs the same scanner over the newest servers " | |
| "in the official MCP registry: no input required, $0.005.", | |
| extensions=_disc( | |
| {"url": "https://example.com/skill.md"}, | |
| {"properties": {"url": {"type": "string", "format": "uri", "description": "URL to fetch + audit"}}, "required": ["url"]}, | |
| {"url": "https://example.com/skill.md", "risk_score": 0, "risk_level": "clean", "total_findings": 0, "findings": []}, | |
| ), | |
| ), | |
| "POST /read": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Fetch a URL and return its main content as clean Markdown (boilerplate stripped)", | |
| extensions=_disc( | |
| {"url": "https://example.com/article"}, | |
| {"properties": { | |
| "url": {"type": "string", "format": "uri", "description": "Page to fetch and clean"}, | |
| "include_links": {"type": "boolean", "description": "Keep hyperlinks in the Markdown (default true)"}, | |
| }, "required": ["url"]}, | |
| {"url": "https://example.com/article", "title": "Article title", | |
| "markdown": "# Article title\n\nMain content…", "word_count": 1234}, | |
| ), | |
| ), | |
| "POST /read/batch": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.02", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Fetch up to 10 URLs concurrently and return each as clean Markdown (bulk rate)", | |
| extensions=_disc( | |
| {"urls": ["https://example.com/a", "https://example.com/b"]}, | |
| {"properties": { | |
| "urls": {"type": "array", "items": {"type": "string", "format": "uri"}, | |
| "description": "1-10 pages to fetch and clean"}, | |
| "include_links": {"type": "boolean", "description": "Keep hyperlinks (default true)"}, | |
| }, "required": ["urls"]}, | |
| {"count": 2, "ok": 2, "results": [ | |
| {"url": "https://example.com/a", "title": "A", "markdown": "# A…", "word_count": 120}, | |
| ]}, | |
| ), | |
| ), | |
| "POST /extract": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.008", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Extract structured metadata from a page: title, description, OpenGraph, JSON-LD, headings, links", | |
| extensions=_disc( | |
| {"url": "https://example.com/product"}, | |
| {"properties": {"url": {"type": "string", "format": "uri", "description": "Page to extract structured data from"}}, | |
| "required": ["url"]}, | |
| {"url": "https://example.com/product", "title": "Product", "description": "…", | |
| "opengraph": {"og:title": "Product"}, "jsonld": [{"@type": "Product"}], | |
| "headings": [{"level": 1, "text": "Product"}], "links": ["https://example.com/buy"]}, | |
| ), | |
| ), | |
| "POST /pdf": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.01", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Fetch a PDF by URL and return its text as Markdown-ish plain text, page by page", | |
| extensions=_disc( | |
| {"url": "https://example.com/paper.pdf"}, | |
| {"properties": { | |
| "url": {"type": "string", "format": "uri", "description": "PDF to fetch and extract"}, | |
| "max_pages": {"type": "integer", "description": "Page cap (default 50)"}, | |
| }, "required": ["url"]}, | |
| {"url": "https://example.com/paper.pdf", "pages": 12, "extracted_pages": 12, | |
| "text": "# Page 1\n\n…", "word_count": 4200}, | |
| ), | |
| ), | |
| "POST /trust": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.02", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Vet an x402 server or any URL for scam/phishing/trust in ONE call: transport, content-safety, domain, metadata + x402-compliance sub-scores, each with transparent evidence", | |
| extensions=_disc( | |
| {"url": "https://some-x402-api.example.com"}, | |
| {"properties": { | |
| "url": {"type": "string", "format": "uri", "description": "x402 server or URL to vet before trusting/paying it"}, | |
| "check_x402": {"type": "boolean", "description": "Probe for a valid x402 402 challenge (default true)"}, | |
| }, "required": ["url"]}, | |
| {"url": "https://some-x402-api.example.com", "trust_score": 82, "risk_level": "low", | |
| "is_scam": False, "verdict": "Reachable HTTPS endpoint with strong headers, valid x402 challenge, no malicious patterns.", | |
| "sub_scores": {"transport": 90, "content_safety": 100, "domain": 70, "metadata": 80, "x402": 100}, | |
| "x402": {"is_x402": True, "valid_challenge": True, "pay_to": "0x…", "network": "eip155:8453", "asset": "USDC"}, | |
| "evidence": {"missing_headers": ["content-security-policy"], "malicious_findings": 0, | |
| "spf": True, "dmarc": True, "latency_ms": 210}}, | |
| ), | |
| ), | |
| "POST /headers": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Fetch a URL and grade its HTTP security headers (HSTS, CSP, X-Frame-Options, etc.)", | |
| extensions=_disc( | |
| {"url": "https://example.com"}, | |
| {"properties": {"url": {"type": "string", "format": "uri", "description": "URL to inspect"}}, | |
| "required": ["url"]}, | |
| {"url": "https://example.com", "status": 200, "grade": "B", "score": 70, | |
| "headers": {"strict-transport-security": "max-age=63072000"}, | |
| "present": ["strict-transport-security"], "missing": ["content-security-policy"]}, | |
| ), | |
| ), | |
| "POST /dns": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.006", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Resolve a domain's DNS records (A/AAAA/MX/NS/TXT/CNAME) and flag basic email/security posture", | |
| extensions=_disc( | |
| {"domain": "example.com"}, | |
| {"properties": {"domain": {"type": "string", "description": "Domain to resolve (or a full URL)"}}, | |
| "required": ["domain"]}, | |
| {"domain": "example.com", "records": {"A": ["93.184.216.34"], "MX": [], "TXT": []}, | |
| "flags": {"has_spf": False, "has_dmarc": False, "has_mx": False}}, | |
| ), | |
| ), | |
| "POST /search": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.01", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="What does the open web say about this query? Ranked title/url/snippet results for agents, served through an automatic multi-engine failover chain so a single call still answers when any one backend is blocked, rate-limited or timing out from a datacenter IP.", | |
| extensions=_disc( | |
| {"query": "x402 protocol spec"}, | |
| {"properties": { | |
| "query": {"type": "string", "description": "Search query"}, | |
| "max_results": {"type": "integer", "description": "1-25 results (default 10)"}, | |
| }, "required": ["query"]}, | |
| {"query": "x402 protocol spec", "engine": "duckduckgo", "count": 10, | |
| "results": [{"rank": 1, "title": "x402 spec", "url": "https://example.com/spec", | |
| "snippet": "The x402 protocol defines…"}]}, | |
| ), | |
| ), | |
| "POST /crawl": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.02", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Crawl a site from a start URL (same-domain, breadth-first) and return each page as clean Markdown", | |
| extensions=_disc( | |
| {"url": "https://example.com/docs", "max_pages": 5}, | |
| {"properties": { | |
| "url": {"type": "string", "format": "uri", "description": "Start URL"}, | |
| "max_pages": {"type": "integer", "description": "1-10 pages to fetch (default 5)"}, | |
| "include_links": {"type": "boolean", "description": "Keep hyperlinks (default true)"}, | |
| }, "required": ["url"]}, | |
| {"start_url": "https://example.com/docs", "pages_fetched": 5, "ok": 5, | |
| "pages": [{"url": "https://example.com/docs", "title": "Docs", | |
| "markdown": "# Docs…", "word_count": 800, "depth": 0}]}, | |
| ), | |
| ), | |
| "POST /sitemap": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Enumerate a site's URLs from robots.txt + sitemap.xml (sitemap-index aware) — map a domain before crawling it", | |
| extensions=_disc( | |
| {"url": "https://example.com"}, | |
| {"properties": { | |
| "url": {"type": "string", "description": "Site URL or bare domain"}, | |
| "max_urls": {"type": "integer", "description": "Cap on returned URLs (default 500, max 2000)"}, | |
| }, "required": ["url"]}, | |
| {"site": "https://example.com", "sitemaps": ["https://example.com/sitemap.xml"], | |
| "count": 120, "urls": [{"loc": "https://example.com/a", "lastmod": "2026-07-01"}]}, | |
| ), | |
| ), | |
| "POST /rss": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Parse an RSS/Atom feed into structured items (title, link, published, summary) — or auto-discover the feed from a site URL", | |
| extensions=_disc( | |
| {"url": "https://example.com/feed.xml"}, | |
| {"properties": { | |
| "url": {"type": "string", "description": "Feed URL, or a site URL to auto-discover its feed"}, | |
| "max_items": {"type": "integer", "description": "Cap on items (default 50)"}, | |
| }, "required": ["url"]}, | |
| {"feed_url": "https://example.com/feed.xml", "title": "Example Blog", "format": "rss", | |
| "count": 2, "items": [{"title": "Post", "link": "https://example.com/post", | |
| "published": "2026-07-20T10:00:00Z", "summary": "…"}]}, | |
| ), | |
| ), | |
| "POST /audit/repo": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Scan an entire public GitHub repo for malicious AI-skill/supply-chain " | |
| "patterns. Requires `repo`. No repo in mind? POST /audit/registry runs " | |
| "this same scanner over the newest servers in the official MCP " | |
| "registry: no input required, $0.005.", | |
| extensions=_disc( | |
| {"repo": "owner/name"}, | |
| {"properties": { | |
| "repo": {"type": "string", "description": "GitHub repo as 'owner/name' or full URL"}, | |
| "ref": {"type": "string", "description": "Branch/tag/sha (default: HEAD)"}, | |
| }, "required": ["repo"]}, | |
| {"repo": "owner/name", "files_scanned": 84, "risk_score": 0, "risk_level": "clean", | |
| "total_findings": 0, "flagged_files": []}, | |
| ), | |
| ), | |
| # The zero-input security route. `required` is deliberately EMPTY — a crawler that | |
| # calls this with `{}` still gets a real answer, which the measurements say is the | |
| # difference between converting and not. See RegistryAuditRequest. | |
| "POST /audit/registry": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price="$0.005", network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description="Scan the newest servers in the official MCP registry for " | |
| "supply-chain risk — no input required", | |
| extensions=_disc( | |
| {}, | |
| {"properties": { | |
| "limit": {"type": "integer", "description": | |
| "How many of the newest registry servers to scan (1-15, default 5)"}, | |
| }, "required": []}, | |
| {"source": "registry.modelcontextprotocol.io/v0/servers", "scanned": 5, | |
| "flagged": 1, "unreadable": 2, | |
| "results": [{"server": "io.github.owner/name", "repo": "owner/name", | |
| "risk_level": "HIGH", "risk_score": 30, "finding_count": 1, | |
| "files_scanned": ["package.json"], "top_finding": {}}]}, | |
| ), | |
| ), | |
| } | |
| # Merge the mounted families in. Same RouteConfig shape as the literal above; the | |
| # per-route price comes from each module's spec rather than being fixed here. | |
| routes.update({f"POST {path}": RouteConfig( | |
| accepts=[PaymentOption(scheme="exact", pay_to=WALLET, price=price, network=BASE_MAINNET)], | |
| mime_type="application/json", | |
| description=desc, | |
| extensions=_disc(in_ex, in_schema, out_ex), | |
| ) for path, price, desc, in_ex, in_schema, out_ex in _extra_specs}) | |
| # Bazaar discovery metadata — see the same block in tokenguard_api/main.py. A | |
| # 14,271-resource scan on 2026-07-27 showed all of our listings with | |
| # serviceName=None and tags=[], i.e. absent from every tag-filtered buyer query. | |
| # Caps: service_name <= 32 chars, <= 5 tags of <= 32 chars each. | |
| _TAGS = ( | |
| ("/read", ["web", "scraping", "content", "data", "ai-agents"]), | |
| ("/search", ["search", "web", "data", "ai-agents", "tools"]), | |
| ("/crawl", ["web", "scraping", "search", "data", "ai-agents"]), | |
| ("/sitemap", ["web", "scraping", "search", "data", "ai-agents"]), | |
| ("/rss", ["web", "feeds", "content", "data", "ai-agents"]), | |
| ("/extract", ["web", "scraping", "content", "data", "ai-agents"]), | |
| ("/pdf", ["web", "documents", "content", "data", "ai-agents"]), | |
| ("/dns", ["web", "infrastructure", "data", "ai-agents", "tools"]), | |
| ("/headers", ["web", "infrastructure", "data", "ai-agents", "tools"]), | |
| ("/trust", ["verification", "security", "web", "data", "ai-agents"]), | |
| ("/audit", ["verification", "security", "developer", "data", "ai-agents"]), | |
| ) | |
| for _key, _cfg in routes.items(): | |
| _path = _key.split(" ", 1)[-1] | |
| if not getattr(_cfg, "tags", None): | |
| for _prefix, _tags in _TAGS: | |
| if _path.startswith(_prefix): | |
| _cfg.tags = _tags | |
| break | |
| else: | |
| _cfg.tags = ["web", "data", "ai-agents", "tools", "api"] | |
| if not getattr(_cfg, "service_name", None): | |
| _cfg.service_name = "skill-audit" | |
| app.add_middleware(PaymentMiddlewareASGI, routes=routes, server=server) | |
| # `/.well-known/x402` publishes bare URL strings, so the free `paid_catalogue` MCP tool | |
| # cannot tell an agent which routes need no input — the property that predicts conversion. | |
| try: | |
| import mcp_http | |
| mcp_http.set_routes(routes) | |
| except Exception as _e: # noqa: BLE001 | |
| print(f"mcp_http.set_routes skipped: {type(_e).__name__}: {_e}") | |
| _x402_available = True | |
| except Exception as e: # pragma: no cover | |
| print(f" x402 v2 init warning: {type(e).__name__}: {e}") | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Models | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| class RegistryAuditRequest(BaseModel): | |
| """Deliberately has NO required field — that is the entire point of the route. | |
| Measured 2026-08-10 across ~14,000 paywall challenges, with `POST /mcp 200` excluded: | |
| routes callable with NO required input : 12 routes, 397 paid, mean 33.1 | |
| routes REQUIRING an argument : 9 routes, 12 paid, mean 1.3 | |
| A 25x gap that cuts cleanly across price AND category. /btc_rates at $0.020 with no | |
| argument converts; /check at $0.005 needing an `address` converted once in 5,608. The | |
| buyers are crawlers walking a directory of x402 endpoints and calling them blind: they can | |
| pay for /stocks because `{}` returns something worth $0.005, and they cannot pay for | |
| /audit because they have no text of ours to audit. | |
| So the security capability was never unsellable — it was the wrong SHAPE. This is the same | |
| scanner behind /audit, pointed at a target the caller does not have to supply. | |
| """ | |
| limit: Optional[int] = 5 # newest N registry servers; capped at 15 below | |
| class AuditRequest(BaseModel): | |
| content: str | |
| class AuditUrlRequest(BaseModel): | |
| url: str | |
| max_size: Optional[int] = 500_000 # 500KB default limit | |
| class ReadRequest(BaseModel): | |
| url: str | |
| include_links: Optional[bool] = True | |
| max_size: Optional[int] = 2_000_000 # 2MB raw HTML cap | |
| class ReadBatchRequest(BaseModel): | |
| urls: list[str] | |
| include_links: Optional[bool] = True | |
| class ExtractRequest(BaseModel): | |
| url: str | |
| class PdfRequest(BaseModel): | |
| url: str | |
| max_pages: Optional[int] = 50 | |
| class RepoAuditRequest(BaseModel): | |
| repo: str | |
| ref: Optional[str] = None | |
| include_tests: Optional[bool] = False # test/doc/changelog files are false-positive factories | |
| class TrustRequest(BaseModel): | |
| url: str | |
| check_x402: Optional[bool] = True | |
| class SearchRequest(BaseModel): | |
| query: str | |
| max_results: Optional[int] = 10 | |
| class CrawlRequest(BaseModel): | |
| url: str | |
| max_pages: Optional[int] = 5 | |
| include_links: Optional[bool] = True | |
| class SitemapRequest(BaseModel): | |
| url: str | |
| max_urls: Optional[int] = 500 | |
| class RssRequest(BaseModel): | |
| url: str | |
| max_items: Optional[int] = 50 | |
| class HeadersRequest(BaseModel): | |
| url: str | |
| class DnsRequest(BaseModel): | |
| domain: str | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Endpoints | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| async def root(): | |
| pattern_count = sum(len(pgs) for pgs in PATTERNS.values()) | |
| signature_count = sum(len(pg["regexes"]) for pgs in PATTERNS.values() for pg in pgs) | |
| return { | |
| "service": "skill-audit API", | |
| "version": "2.1.0", | |
| "description": "Detect malicious patterns in AI agent skills, plugins, and prompts.", | |
| "signature_count": signature_count, | |
| "detects": [ | |
| "download-and-execute", "credential exfiltration", "key generation", | |
| "prompt injection", "privilege escalation", "code execution", | |
| "identity impersonation", "seed phrase harvesting", | |
| ], | |
| "pattern_count": pattern_count, | |
| "endpoints": { | |
| "GET /": "Service info (free)", | |
| "GET /health": "Health check (free)", | |
| "POST /audit": "Audit text content ($0.005 USDC)", | |
| "POST /audit/url": "Fetch URL + audit ($0.005 USDC)", | |
| "POST /read": "Fetch URL → clean Markdown ($0.005 USDC)", | |
| "POST /read/batch": "Up to 10 URLs → clean Markdown, concurrent ($0.02 USDC)", | |
| "POST /search": "Web search → ranked title/url/snippet results, no API key ($0.01 USDC)", | |
| "POST /crawl": "Crawl a site same-domain BFS → each page as clean Markdown ($0.02 USDC)", | |
| "POST /sitemap": "robots.txt + sitemap.xml → full URL map of a domain ($0.005 USDC)", | |
| "POST /rss": "RSS/Atom feed (or auto-discovered from a site) → structured items ($0.005 USDC)", | |
| "POST /extract": "URL → structured metadata / OpenGraph / JSON-LD / links ($0.008 USDC)", | |
| "POST /pdf": "PDF URL → extracted text ($0.01 USDC)", | |
| "POST /audit/repo": "Public GitHub repo → full malicious-pattern scan ($0.005 USDC)", | |
| "POST /audit/registry": "Newest MCP-registry servers → supply-chain risk feed, no input required ($0.005 USDC)", | |
| "POST /trust": "Vet any URL / x402 server for scam+trust in one call (5 sub-scores + evidence + x402 check) ($0.02 USDC)", | |
| "POST /headers": "URL → HTTP security-header grade ($0.005 USDC)", | |
| "POST /dns": "Domain → DNS records + email/security posture ($0.006 USDC)", | |
| }, | |
| "payment": { | |
| "method": "x402", | |
| "x402_version": 2, | |
| "currency": "USDC", | |
| "network": "Base (eip155:8453)", | |
| "facilitator": FACILITATOR_URL, | |
| "wallet": WALLET, | |
| "x402_enabled": _x402_available, | |
| }, | |
| } | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "timestamp": datetime.utcnow().isoformat() + "Z", | |
| "x402_enabled": _x402_available, | |
| } | |
| # Public base URL of this origin (for the x402 discovery document). Override per-Space. | |
| PUBLIC_BASE = os.environ.get("PUBLIC_BASE_URL", "https://eltociear-skill-audit.hf.space").rstrip("/") | |
| # Paid resources advertised in /.well-known/x402 so on-chain explorers (x402scan, | |
| # CDP Bazaar) can auto-discover and index this origin's endpoints. | |
| _PAID_PATHS = [ | |
| "/audit", "/audit/url", "/audit/repo", "/audit/registry", "/trust", "/read", "/read/batch", | |
| "/extract", "/pdf", "/headers", "/dns", "/search", "/crawl", "/sitemap", "/rss", | |
| ] | |
| # Same measured-demand story as /llms.txt below, one sample later: a 90s access-log read on | |
| # 2026-08-07 caught this Space 404ing GET /x402-resources, /api and /api/v1 — and the other | |
| # two Spaces already served the resources shape. Every path added here is one real traffic | |
| # asked for and did not get. | |
| # | |
| # ⚠ GET/POST/HEAD, not GET, and `.json` is here too. Measured 2026-08-17 on contract-guard: | |
| # one access-log buffer carried 8,689 405s against 7,657 402s, all of them discovery probes | |
| # from 262 distinct IPs POSTing to these paths. GET-only registration turned every one into | |
| # a bare "405 Method Not Allowed" — no price, no catalogue. That is | |
| # `x402-405-hides-the-paywall` again with the verbs swapped, and this Space was missing four | |
| # of the aliases the other two already served on top of it. | |
| async def well_known_x402(): | |
| """x402 discovery document (x402scan/Bazaar auto-index). version-1 schema: | |
| {version, resources[], instructions}. ownershipProofs omitted (listed as | |
| unverified) — a wallet-signed proof can be added later for the verified badge.""" | |
| return { | |
| "version": 1, | |
| "resources": [PUBLIC_BASE + p for p in _PAID_PATHS], | |
| "instructions": ( | |
| "# skill-audit x402 API\n\n" | |
| "Security + web-data API suite for AI agents. Pay-per-call in USDC on Base " | |
| "(eip155:8453) via x402 v2 — no signup, no API key.\n\n" | |
| "| Endpoint | Price | Description |\n|---|---|---|\n" | |
| "| `POST /audit` | $0.005 | Scan text for malicious AI-skill / prompt-injection patterns |\n" | |
| "| `POST /audit/url` | $0.005 | Fetch a URL and audit it |\n" | |
| "| `POST /audit/repo` | $0.005 | Scan a whole public GitHub repo (tests/docs excluded) |\n" | |
| "| `POST /audit/registry` | $0.005 | Newest MCP-registry servers scanned for supply-chain risk — **no input required** |\n" | |
| "| `POST /trust` | $0.02 | Vet any URL / x402 server for scam+trust — 5 sub-scores + evidence + x402 check, one call |\n" | |
| "| `POST /search` | $0.01 | Web search → ranked title/url/snippet, no API key or signup |\n" | |
| "| `POST /read` | $0.005 | URL → clean Markdown |\n" | |
| "| `POST /crawl` | $0.02 | Crawl a site same-domain (BFS) → every page as clean Markdown |\n" | |
| "| `POST /sitemap` | $0.005 | robots.txt + sitemap.xml → full URL map of a domain |\n" | |
| "| `POST /rss` | $0.005 | RSS/Atom feed (auto-discovered from a site URL) → structured items |\n" | |
| "| `POST /read/batch` | $0.02 | Up to 10 URLs → clean Markdown, concurrent |\n" | |
| "| `POST /extract` | $0.008 | URL → OpenGraph / JSON-LD / headings / links |\n" | |
| "| `POST /pdf` | $0.01 | PDF URL → extracted text |\n" | |
| "| `POST /headers` | $0.005 | URL → HTTP security-header grade A–F |\n" | |
| "| `POST /dns` | $0.006 | Domain → DNS records + SPF/DMARC posture |\n\n" | |
| f"Payments settle to `{WALLET}`. Facilitator: {FACILITATOR_URL}." | |
| ), | |
| "payTo": [WALLET], | |
| "network": BASE_MAINNET, | |
| } | |
| # ── discovery files agents ACTUALLY request here and 404'd on (measured 2026-08-02: | |
| # this Space is the highest-traffic of the three yet was the only one missing them — | |
| # /llms.txt and the A2A agent-card, which a real population of distinct agents probes). | |
| # tokenguard + contract-guard already serve these; skill-audit did not. Serving them | |
| # is answering demand whose audience is already attached. | |
| # Prices for /pricing and /x402-resources. These MUST stay in step with the Markdown table | |
| # in `instructions` above and with the paywall itself -- a price list that disagrees with | |
| # what is actually charged is worse than no price list. Asserted at import below. | |
| _PRICE_TABLE = { | |
| # audit* moved to $0.005 on 2026-08-10 — see the price-experiment note above `routes`. | |
| "/audit": "$0.005", "/audit/url": "$0.005", "/audit/repo": "$0.005", | |
| "/audit/registry": "$0.005", "/trust": "$0.02", | |
| "/search": "$0.01", "/read": "$0.005", "/crawl": "$0.02", "/sitemap": "$0.005", | |
| "/rss": "$0.005", "/read/batch": "$0.02", "/extract": "$0.008", "/pdf": "$0.01", | |
| "/headers": "$0.005", "/dns": "$0.006", | |
| } | |
| # Every advertised paid path must carry a price. Without this, adding a route to | |
| # _PAID_PATHS and forgetting the price ships a resource quoting `""` to the indexers. | |
| assert set(_PRICE_TABLE) == set(_PAID_PATHS), ( | |
| f"price table and paid paths disagree: " | |
| f"missing={sorted(set(_PAID_PATHS) - set(_PRICE_TABLE))} " | |
| f"extra={sorted(set(_PRICE_TABLE) - set(_PAID_PATHS))}") | |
| # -- what a discovering agent actually READS -------------------------------------------- | |
| # The description is the only text an indexing crawler stores about a route, and the only | |
| # thing an agent has when choosing between ours and an incumbent's. Measured 2026-08-17: | |
| # this Space served 15 resources with NO description field at all -- 15 nameless URLs -- | |
| # while tokenguard and contract-guard both carried one. The text already existed, in the | |
| # `endpoints` map of the service-info payload; it was simply never attached per resource. | |
| # | |
| # Asserted against _PAID_PATHS for the same reason the price table is: adding a route and | |
| # forgetting its description ships an anonymous resource to every indexer. | |
| _DESC_TABLE = { | |
| "/audit": "Scan text for malicious AI-skill and prompt-injection patterns; returns the " | |
| "matched signatures with evidence, not just a verdict.", | |
| "/audit/url": "Fetch a URL and audit its content for malicious AI-skill and " | |
| "prompt-injection patterns.", | |
| "/audit/repo": "Scan a whole public GitHub repo for malicious patterns, with coverage " | |
| "reported so a clean result carries its own denominator.", | |
| "/audit/registry": "Newest MCP-registry servers scanned for supply-chain risk -- a " | |
| "feed, and it needs no input at all.", | |
| "/trust": "Vet any URL or x402 server for scam and trust risk in one call: five " | |
| "sub-scores, the evidence behind each, and an x402 liveness check.", | |
| "/read": "URL to clean Markdown, boilerplate stripped, ready to feed a model.", | |
| "/read/batch": "Up to 10 URLs to clean Markdown, fetched concurrently.", | |
| "/extract": "URL to structured metadata: OpenGraph, JSON-LD, canonical links.", | |
| "/pdf": "PDF URL to its extracted text, page by page, with the page count.", | |
| "/headers": "URL to an HTTP security-header grade, with the per-header findings.", | |
| "/dns": "Domain to DNS records plus its email and security posture (SPF, DMARC, DNSSEC).", | |
| "/search": "Web search to ranked title/url/snippet results. No API key, no signup.", | |
| "/crawl": "Crawl a site same-domain breadth-first, every page as clean Markdown.", | |
| "/sitemap": "robots.txt and sitemap.xml to a full URL map of a domain.", | |
| "/rss": "RSS or Atom feed, auto-discovered from a site URL, to structured items.", | |
| } | |
| assert set(_DESC_TABLE) == set(_PAID_PATHS), ( | |
| f"description table and paid paths disagree: " | |
| f"missing={sorted(set(_PAID_PATHS) - set(_DESC_TABLE))} " | |
| f"extra={sorted(set(_DESC_TABLE) - set(_PAID_PATHS))}") | |
| async def x402_resources(): | |
| """Expanded resource list. Indexers ask for this shape (full `accepts` per resource) | |
| rather than the bare URL array in /.well-known/x402, and this Space was the only one of | |
| the three not serving it — its own access log shows the path being requested.""" | |
| items = [{ | |
| "resource": PUBLIC_BASE + p, | |
| "url": PUBLIC_BASE + p, | |
| "method": "POST", | |
| "type": "http", | |
| "x402Version": 2, | |
| "mimeType": "application/json", | |
| "description": _DESC_TABLE.get(p, ""), | |
| "accepts": [{ | |
| "scheme": "exact", | |
| "network": BASE_MAINNET, | |
| "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC on Base | |
| "payTo": WALLET, | |
| "price": _PRICE_TABLE.get(p, ""), | |
| }], | |
| } for p in _PAID_PATHS] | |
| return {"x402Version": 2, "count": len(items), "resources": items, "items": items} | |
| async def api_index(): | |
| return { | |
| "service": "skill-audit", | |
| "description": "Security + web-data API suite for AI agents.", | |
| "paid_endpoint_count": len(_PAID_PATHS), | |
| "payment": {"protocol": "x402", "asset": "USDC", "network": BASE_MAINNET, | |
| "payTo": WALLET}, | |
| "discovery": { | |
| "x402": PUBLIC_BASE + "/.well-known/x402", | |
| "resources": PUBLIC_BASE + "/x402-resources", | |
| "pricing": PUBLIC_BASE + "/pricing", | |
| "terms": PUBLIC_BASE + "/terms", | |
| "agent_card": PUBLIC_BASE + "/.well-known/agent-card.json", | |
| "openapi": PUBLIC_BASE + "/openapi.json", | |
| }, | |
| } | |
| async def pricing(): | |
| return { | |
| "currency": "USDC", "network": BASE_MAINNET, "payTo": WALLET, | |
| "protocol": "x402", "unit": "per request", "count": len(_PAID_PATHS), | |
| "paid": [{"method": "POST", "path": p, "price": _PRICE_TABLE.get(p, "")} | |
| for p in _PAID_PATHS], | |
| } | |
| async def terms(): | |
| """Terms an autonomous buyer can read before spending.""" | |
| return { | |
| "service": "skill-audit", | |
| "operator": "eltociear", | |
| "payment": {"protocol": "x402", "asset": "USDC", "network": BASE_MAINNET, | |
| "payTo": WALLET, | |
| "refunds": "none — each call is priced and settled per request"}, | |
| "data": { | |
| "inputs_logged": "request path and parameters, for rate limiting only", | |
| "personal_data_collected": "none — there is no account and no API key", | |
| "retention": "access logs only, as retained by the hosting platform", | |
| }, | |
| "warranty": "none. Audit output is a heuristic signal from static pattern " | |
| "matching, not a security guarantee.", | |
| "acceptable_use": "scan only targets you are authorised to scan", | |
| } | |
| async def sitemap(): | |
| paths = ["/", "/pricing", "/terms", "/llms.txt", "/openapi.json", | |
| "/.well-known/x402", "/x402-resources", "/.well-known/agent-card.json"] | |
| urls = "".join(f"<url><loc>{PUBLIC_BASE}{p}</loc></url>" for p in paths) | |
| return Response( | |
| '<?xml version="1.0" encoding="UTF-8"?>' | |
| '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' | |
| f"{urls}</urlset>", | |
| media_type="application/xml") | |
| async def m2m_openapi(): | |
| """Alias: something in the wild probes this name for a machine-readable spec.""" | |
| return app.openapi() | |
| async def llms_txt(): | |
| """llms.txt — the machine-readable site summary crawlers/agents probe for.""" | |
| lines = ["# skill-audit", "", | |
| "> Security + web-data API suite for AI agents. Pay-per-call in USDC on " | |
| "Base (eip155:8453) via x402 v2 — no signup, no API key.", "", | |
| "## Paid endpoints (x402)"] | |
| lines += [f"- POST {PUBLIC_BASE}{p}" for p in _PAID_PATHS] | |
| lines += ["", f"x402 discovery: {PUBLIC_BASE}/.well-known/x402", | |
| f"Agent card: {PUBLIC_BASE}/.well-known/agent-card.json", | |
| f"Payments settle to {WALLET}."] | |
| return Response("\n".join(lines) + "\n", media_type="text/plain; charset=utf-8") | |
| async def agent_card(): | |
| """A2A agent card — how agent directories and peers introspect a service. Measured | |
| 2026-08-02: distinct A2A agents probe these exact paths on this Space and got 404, | |
| i.e. they are trying to discover us as a peer and failing.""" | |
| return { | |
| "protocolVersion": "0.3.0", | |
| "name": "skill-audit", | |
| "description": ("Pay-per-call security + web-data API for AI agents: malicious-skill / " | |
| "prompt-injection scanning, URL trust/scam vetting, web search, and " | |
| "URL-to-Markdown reading. USDC on Base via x402 — no signup."), | |
| "url": PUBLIC_BASE, | |
| "provider": {"organization": "eltociear", "url": "https://github.com/eltociear"}, | |
| "version": "1.0.0", | |
| "capabilities": {"streaming": False, "pushNotifications": False}, | |
| "defaultInputModes": ["application/json"], | |
| "defaultOutputModes": ["application/json"], | |
| "skills": [{ | |
| "id": p.strip("/").replace("/", "_"), | |
| "name": p, | |
| "description": f"POST {p} — x402 paid", | |
| "tags": ["x402", "paid", "security", "web-data"], | |
| } for p in _PAID_PATHS], | |
| "payment": {"protocol": "x402", "version": 2, "network": BASE_MAINNET, | |
| "currency": "USDC", "payTo": WALLET}, | |
| } | |
| async def audit_text(req: AuditRequest): | |
| content = req.content | |
| if not content or not content.strip(): | |
| raise HTTPException(400, "content is required and must not be empty") | |
| if len(content) > 1_000_000: | |
| raise HTTPException(413, "content too large (max 1MB)") | |
| result = scan(content) | |
| return { | |
| "risk_score": result["risk_score"], | |
| "risk_level": result["risk_level"], | |
| "summary": result["summary"], | |
| "total_findings": result["total_findings"], | |
| "findings": result["findings"], | |
| } | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # OKX AI Agent Marketplace variant — X Layer (eip155:196) 402 challenge. | |
| # OKX's task system settles via its own escrow and only accepts USDT/USDG on X Layer, so | |
| # this route advertises a hand-built X-Layer challenge (OKX reads it during review and pays | |
| # via escrow). It is DELIBERATELY outside the x402 middleware `routes` dict so it does not | |
| # touch the live Base/USDC settlement path — Base earners are unaffected. On a request that | |
| # already carries an X-PAYMENT header (OKX escrow settled), it runs the real audit. | |
| import base64 as _b64 | |
| import json as _json | |
| # USDT0 on X Layer, 6 dec — the ONLY payment token OKX review accepts (rejection 2026-07-26: | |
| # "Please make sure the token for payment is USDT0"). EIP-712 domain verified on-chain against | |
| # DOMAIN_SEPARATOR 0xd591d9ba…: name="USD₮0", version="1", chainId 196. | |
| # (The older 0x1E4a5963… "Tether USD" is a different X Layer token and gets the listing rejected.) | |
| _XLAYER_USDT = "0x779DEd0c9e1022225f8E0630b35a9b54bE713736" | |
| _OKX_WALLET = os.environ.get("OKX_WALLET", "0xf8ea161baa8dbb47d6c8744c718e29a9d83609b6") | |
| # OKX's 3rd review (2026-07-27) demands the payment go through THEIR broker, not a challenge we | |
| # hand-write. `okx_payments` does that with our own SDK (their "official SDK" is a repackaged, | |
| # older copy of the same library and would collide with the Base earners). It needs OKX Developer | |
| # Portal API keys; without them this stays False and the hand-built challenge below still serves, | |
| # so the listing is never worse off than it is today. | |
| try: | |
| # okx_payments.py sits one level up (scripts/) in both the repo and the deployed build; | |
| # main.py is imported as scripts.x402_api.main, so neither dir is on sys.path by default. | |
| for _p in (os.path.dirname(os.path.abspath(__file__)), | |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__)))): | |
| if _p not in sys.path: | |
| sys.path.insert(0, _p) | |
| import okx_payments as _okx_pay | |
| _OKX_SDK_ACTIVE = _okx_pay.install(app, _OKX_WALLET) | |
| except Exception as _e: # pragma: no cover | |
| _OKX_SDK_ACTIVE = False | |
| print(f" okx payment SDK not installed: {type(_e).__name__}: {_e}") | |
| print(f" okx broker paywall: {'ACTIVE' if _OKX_SDK_ACTIVE else 'inactive (no OKX_* credentials)'}") | |
| def _okx_unpaid(request: Request) -> bool: | |
| """True only when WE still have to emit the challenge. Once the OKX middleware is active it | |
| owns the whole handshake — including its own header names — so the handler must not re-gate.""" | |
| if _OKX_SDK_ACTIVE: | |
| return False | |
| return not (request.headers.get("X-PAYMENT") or request.headers.get("x-payment") | |
| or request.headers.get("PAYMENT-SIGNATURE")) | |
| def _okx_challenge(amount_usdt6: int, resource: str): | |
| doc = { | |
| "x402Version": 2, | |
| "error": "payment required", | |
| "accepts": [{ | |
| "scheme": "exact", | |
| "network": "eip155:196", | |
| "asset": _XLAYER_USDT, | |
| "payTo": _OKX_WALLET, | |
| "amount": str(amount_usdt6), | |
| "maxAmountRequired": str(amount_usdt6), | |
| "resource": resource, | |
| "maxTimeoutSeconds": 300, | |
| "extra": {"name": "USD₮0", "version": "1", "decimals": 6}, | |
| }], | |
| } | |
| return _b64.b64encode(_json.dumps(doc, separators=(",", ":")).encode()).decode() | |
| # GET/POST/HEAD: this is a free diagnostic and the crawler fleet POSTs it. Registered | |
| # GET-only it answered 405, which tells a prober the path is not there at all. Its | |
| # full path is also in mcp_http.FREE_PATHS so tokenguard's blanket POST sweep cannot | |
| # turn a diagnostic into a paid route. | |
| async def okx_status(): | |
| """FREE diagnostic: is the OKX broker paywall live, or are we still on the fallback challenge? | |
| Reports whether credentials are present, never their values.""" | |
| creds = False | |
| try: | |
| creds = bool(_okx_pay.credentials()) | |
| except Exception: | |
| pass | |
| return {"broker_paywall_active": _OKX_SDK_ACTIVE, | |
| "credentials_present": creds, | |
| "mode": "okx-broker" if _OKX_SDK_ACTIVE else "fallback-challenge", | |
| "network": "eip155:196", "asset": _XLAYER_USDT, "pay_to": _OKX_WALLET, | |
| "routes": sorted(["/okx/audit", "/okx/read", "/okx/search", "/okx/extract"])} | |
| async def okx_discover(service: str): | |
| """Discovery probe: a bare GET on any /okx/* service returns the same 402 challenge the POST | |
| does. Without this a reviewer (or `onchainos agent x402-check` with no --body) sees 405 and | |
| reads the service as 'not a valid x402 endpoint'.""" | |
| if service not in ("audit", "read", "search", "extract"): | |
| raise HTTPException(404, "unknown service") | |
| return Response( | |
| status_code=402, | |
| headers={"payment-required": _okx_challenge(10000, f"/okx/{service}")}, | |
| media_type="application/json", | |
| content=_json.dumps({"x402Version": 2, "error": "payment required", | |
| "accepts_network": "eip155:196"}), | |
| ) | |
| async def okx_audit(req: AuditRequest, request: Request): | |
| """OKX-marketplace audit endpoint (X Layer / USDT via OKX escrow). Same scan engine as | |
| /audit; only the payment rail differs (eip155:196 + USDT, settled by OKX).""" | |
| if _okx_unpaid(request): | |
| return Response( | |
| status_code=402, | |
| headers={"payment-required": _okx_challenge(10000, "/okx/audit")}, # $0.01 USDT | |
| media_type="application/json", | |
| content=_json.dumps({"x402Version": 2, "error": "payment required", | |
| "accepts_network": "eip155:196"}), | |
| ) | |
| content = (req.content or "") | |
| if not content.strip(): | |
| raise HTTPException(400, "content is required") | |
| result = scan(content[:1_000_000]) | |
| return {"risk_score": result["risk_score"], "risk_level": result["risk_level"], | |
| "summary": result["summary"], "total_findings": result["total_findings"], | |
| "findings": result["findings"]} | |
| async def okx_search(req: SearchRequest, request: Request): | |
| """OKX-marketplace web search (X Layer / USDT via OKX escrow). Delegates to /search.""" | |
| if _okx_unpaid(request): | |
| return Response( | |
| status_code=402, | |
| headers={"payment-required": _okx_challenge(10000, "/okx/search")}, # $0.01 USDT | |
| media_type="application/json", | |
| content=_json.dumps({"x402Version": 2, "error": "payment required", | |
| "accepts_network": "eip155:196"}), | |
| ) | |
| return await web_search(req) | |
| async def okx_extract(req: ExtractRequest, request: Request): | |
| """OKX-marketplace structured extract (X Layer / USDT via OKX escrow). Delegates to /extract.""" | |
| if _okx_unpaid(request): | |
| return Response( | |
| status_code=402, | |
| headers={"payment-required": _okx_challenge(10000, "/okx/extract")}, # $0.01 USDT | |
| media_type="application/json", | |
| content=_json.dumps({"x402Version": 2, "error": "payment required", | |
| "accepts_network": "eip155:196"}), | |
| ) | |
| return await extract_structured(req) | |
| async def okx_read(req: ReadRequest, request: Request): | |
| """OKX-marketplace clean-read (X Layer / USDT via OKX escrow). Delegates to /read.""" | |
| if _okx_unpaid(request): | |
| return Response( | |
| status_code=402, | |
| headers={"payment-required": _okx_challenge(10000, "/okx/read")}, # $0.01 USDT | |
| media_type="application/json", | |
| content=_json.dumps({"x402Version": 2, "error": "payment required", | |
| "accepts_network": "eip155:196"}), | |
| ) | |
| return await read_url(req) | |
| async def audit_url(req: AuditUrlRequest): | |
| url = req.url | |
| if not url or not url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| import httpx | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=15.0) as client: | |
| resp = await _safe_get(client, url, ua="skill-audit/1.0") | |
| resp.raise_for_status() | |
| except HTTPException: | |
| raise | |
| except httpx.HTTPStatusError as e: | |
| raise HTTPException(502, f"upstream returned {e.response.status_code}") | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| content = resp.text | |
| if len(content) > req.max_size: | |
| content = content[:req.max_size] | |
| result = scan(content) | |
| return { | |
| "url": url, | |
| "content_length": len(content), | |
| "risk_score": result["risk_score"], | |
| "risk_level": result["risk_level"], | |
| "summary": result["summary"], | |
| "total_findings": result["total_findings"], | |
| "findings": result["findings"], | |
| } | |
| async def read_url(req: ReadRequest): | |
| url = req.url | |
| if not url or not url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| import httpx | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=20.0) as client: | |
| resp = await _safe_get(client, url, ua="Mozilla/5.0 (compatible; clean-read/1.0)") | |
| resp.raise_for_status() | |
| except HTTPException: | |
| raise | |
| except httpx.HTTPStatusError as e: | |
| raise HTTPException(502, f"upstream returned {e.response.status_code}") | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| html = resp.text | |
| if len(html) > req.max_size: | |
| html = html[: req.max_size] | |
| try: | |
| import trafilatura | |
| except ImportError: | |
| raise HTTPException(503, "extraction engine unavailable") | |
| markdown = trafilatura.extract( | |
| html, output_format="markdown", | |
| include_links=bool(req.include_links), include_tables=True, favor_recall=True, | |
| ) | |
| if not markdown: | |
| raise HTTPException(422, "could not extract main content from this page") | |
| title = None | |
| try: | |
| meta = trafilatura.extract_metadata(html) | |
| if meta: | |
| title = meta.title | |
| except Exception: | |
| pass | |
| return { | |
| "url": str(resp.url), | |
| "title": title, | |
| "markdown": markdown, | |
| "word_count": len(markdown.split()), | |
| "fetched_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # SSRF guard — these endpoints fetch caller-supplied URLs server-side, so they must | |
| # refuse loopback / private / link-local / reserved targets (incl. cloud metadata at | |
| # 169.254.169.254) and re-validate on every redirect hop. Public URLs are unaffected. | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def _host_is_blocked(host: str) -> bool: | |
| """True if `host` (name or literal) resolves to any non-public IP.""" | |
| import ipaddress, socket | |
| host = host.strip("[]") # strip IPv6 literal brackets | |
| try: # already a literal? | |
| ip = ipaddress.ip_address(host) | |
| return not ip.is_global | |
| except ValueError: | |
| pass | |
| try: | |
| infos = socket.getaddrinfo(host, None) | |
| except Exception: | |
| return True # unresolvable → refuse | |
| for info in infos: | |
| ip_str = info[4][0].split("%")[0] # drop scope id | |
| try: | |
| if not ipaddress.ip_address(ip_str).is_global: | |
| return True | |
| except ValueError: | |
| return True | |
| return False | |
| def _validate_public_url(url: str): | |
| """Raise HTTPException(400) unless url is http(s) with a public host.""" | |
| from urllib.parse import urlparse | |
| p = urlparse(url) | |
| if p.scheme not in ("http", "https") or not p.hostname: | |
| raise HTTPException(400, "valid http/https URL required") | |
| if _host_is_blocked(p.hostname): | |
| raise HTTPException(400, "URL host is not a public address (private/loopback/link-local blocked)") | |
| async def _safe_get(client, url: str, *, ua: str, max_redirects: int = 5): | |
| """GET with manual, SSRF-validated redirect following (each hop re-checked).""" | |
| _validate_public_url(url) | |
| current = url | |
| for _ in range(max_redirects + 1): | |
| resp = await client.get(current, headers={"User-Agent": ua}) | |
| if resp.is_redirect and resp.headers.get("location"): | |
| from urllib.parse import urljoin | |
| current = urljoin(current, resp.headers["location"]) | |
| _validate_public_url(current) # block redirect-to-internal | |
| continue | |
| return resp | |
| raise HTTPException(400, "too many redirects") | |
| async def _fetch_text(url: str, max_size: int, timeout: float = 20.0, ua: str = "Mozilla/5.0 (compatible; clean-read/1.0)"): | |
| """Fetch a URL and return its decoded body, truncated to max_size (SSRF-guarded).""" | |
| import httpx | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client: | |
| resp = await _safe_get(client, url, ua=ua) | |
| resp.raise_for_status() | |
| html = resp.text | |
| return (html[:max_size] if len(html) > max_size else html), str(resp.url) | |
| def _to_markdown(html: str, include_links: bool): | |
| """trafilatura extraction shared by /read and /read/batch. Returns (markdown, title).""" | |
| import trafilatura | |
| markdown = trafilatura.extract( | |
| html, output_format="markdown", | |
| include_links=include_links, include_tables=True, favor_recall=True, | |
| ) | |
| title = None | |
| try: | |
| meta = trafilatura.extract_metadata(html) | |
| if meta: | |
| title = meta.title | |
| except Exception: | |
| pass | |
| return markdown, title | |
| async def read_batch(req: ReadBatchRequest): | |
| urls = [u for u in (req.urls or []) if u] | |
| if not urls: | |
| raise HTTPException(400, "urls is required and must not be empty") | |
| if len(urls) > 10: | |
| raise HTTPException(413, "max 10 urls per batch") | |
| for u in urls: | |
| if not u.startswith(("http://", "https://")): | |
| raise HTTPException(400, f"valid http/https URL required: {u}") | |
| import asyncio | |
| async def one(u: str): | |
| try: | |
| html, final = await _fetch_text(u, 2_000_000) | |
| markdown, title = _to_markdown(html, bool(req.include_links)) | |
| if not markdown: | |
| return {"url": u, "error": "could not extract main content"} | |
| return {"url": final, "title": title, "markdown": markdown, | |
| "word_count": len(markdown.split())} | |
| except Exception as e: | |
| return {"url": u, "error": f"{type(e).__name__}: {e}"} | |
| results = await asyncio.gather(*[one(u) for u in urls]) | |
| return { | |
| "count": len(results), | |
| "ok": sum(1 for r in results if "markdown" in r), | |
| "results": results, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Agent web-data tier: /search /crawl /sitemap /rss | |
| # These extend the proven /read product (fetch → structured text for agents). | |
| # All outbound fetches go through _safe_get/_fetch_text, so the SSRF guard applies. | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| _SEARCH_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") | |
| def _unwrap_ddg(href: str) -> str: | |
| """DDG html wraps results as //duckduckgo.com/l/?uddg=<urlencoded target>.""" | |
| from urllib.parse import urlparse, parse_qs, unquote | |
| if "uddg=" in href: | |
| q = parse_qs(urlparse(href if href.startswith("http") else "https:" + href).query) | |
| if q.get("uddg"): | |
| return unquote(q["uddg"][0]) | |
| return href | |
| def _parse_ddg(html: str, limit: int): | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out = [] | |
| # Anchor-first: DDG nests several `result*`-classed divs per hit, so selecting | |
| # containers double-counts. One anchor == one hit; snippet lives in its ancestor. | |
| seen = set() | |
| for a in doc.xpath("//a[contains(@class,'result__a')]"): | |
| url = _unwrap_ddg(a.get("href") or "") | |
| if not url.startswith("http") or url in seen: | |
| continue | |
| if "duckduckgo.com/y.js" in url or "ad_provider" in url: # sponsored | |
| continue | |
| seen.add(url) | |
| snip = a.xpath("ancestor::div[contains(@class,'result')][1]" | |
| "//*[contains(@class,'result__snippet')]") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a.text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(snip[0].text_content().split()) if snip else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def _parse_ddg_lite(html: str, limit: int): | |
| """lite.duckduckgo.com/lite — table layout: a.result-link + td.result-snippet.""" | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out, seen = [], set() | |
| for a in doc.xpath("//a[contains(@class,'result-link')]"): | |
| url = _unwrap_ddg(a.get("href") or "") | |
| if not url.startswith("http") or url in seen: | |
| continue | |
| seen.add(url) | |
| snip = a.xpath("ancestor::tr[1]/following-sibling::tr[1]//td[contains(@class,'result-snippet')]") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a.text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(snip[0].text_content().split()) if snip else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| def _parse_mojeek(html: str, limit: int): | |
| from lxml import html as lh | |
| doc = lh.fromstring(html) | |
| out = [] | |
| for li in doc.xpath("//ul[contains(@class,'results-standard')]/li"): | |
| a = li.xpath(".//a[contains(@class,'title')] | .//h2/a") | |
| if not a: | |
| continue | |
| url = a[0].get("href") or "" | |
| if not url.startswith("http"): | |
| continue | |
| p = li.xpath(".//p[@class='s']") | |
| out.append({"rank": len(out) + 1, | |
| "title": " ".join(a[0].text_content().split()), | |
| "url": url, | |
| "snippet": " ".join(p[0].text_content().split()) if p else None}) | |
| if len(out) >= limit: | |
| break | |
| return out | |
| async def web_search(req: SearchRequest): | |
| query = (req.query or "").strip() | |
| if not query: | |
| raise HTTPException(400, "query is required") | |
| limit = max(1, min(int(req.max_results or 10), 25)) | |
| from urllib.parse import quote_plus | |
| engines = [ | |
| ("duckduckgo", f"https://html.duckduckgo.com/html/?q={quote_plus(query)}", _parse_ddg), | |
| ("duckduckgo-lite", f"https://lite.duckduckgo.com/lite/?q={quote_plus(query)}", _parse_ddg_lite), | |
| ("mojeek", f"https://www.mojeek.com/search?q={quote_plus(query)}", _parse_mojeek), | |
| ] | |
| errors = {} | |
| for name, url, parse in engines: | |
| try: | |
| html, _ = await _fetch_text(url, 3_000_000, timeout=20.0, ua=_SEARCH_UA) | |
| results = parse(html, limit) | |
| if results: | |
| return {"query": query, "engine": name, "count": len(results), | |
| "results": results, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| errors[name] = "no results parsed" | |
| except Exception as e: | |
| # Class only: the message can carry our upstream URLs and transport detail, and | |
| # these backends are OUR infrastructure, not something the caller supplied. | |
| errors[name] = type(e).__name__ | |
| print(f" search backend {name} failed: {type(e).__name__}: {e}") | |
| # Nothing usable — 502 so the payment middleware does not settle a useless call. | |
| raise HTTPException(502, f"all search backends failed or returned nothing: {errors}") | |
| async def crawl_site(req: CrawlRequest): | |
| start = (req.url or "").strip() | |
| if not start.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| max_pages = max(1, min(int(req.max_pages or 5), 10)) | |
| from urllib.parse import urljoin, urldefrag, urlparse | |
| from lxml import html as lh | |
| origin_host = urlparse(start).hostname or "" | |
| seen = {urldefrag(start)[0]} | |
| queue = [(urldefrag(start)[0], 0)] | |
| pages, failures = [], [] | |
| while queue and len(pages) < max_pages: | |
| current, depth = queue.pop(0) | |
| try: | |
| raw, final = await _fetch_text(current, 2_000_000) | |
| except Exception as e: | |
| failures.append({"url": current, "error": f"{type(e).__name__}: {e}"}) | |
| continue | |
| markdown, title = _to_markdown(raw, bool(req.include_links)) | |
| if markdown: | |
| pages.append({"url": final, "title": title, "markdown": markdown, | |
| "word_count": len(markdown.split()), "depth": depth}) | |
| else: | |
| failures.append({"url": final, "error": "could not extract main content"}) | |
| if len(pages) + len(queue) >= max_pages: | |
| continue | |
| try: # enqueue same-domain children | |
| doc = lh.fromstring(raw) | |
| for a in doc.xpath("//a[@href]"): | |
| link = urldefrag(urljoin(final, a.get("href")))[0] | |
| if not link.startswith(("http://", "https://")) or link in seen: | |
| continue | |
| if (urlparse(link).hostname or "") != origin_host: | |
| continue | |
| seen.add(link) | |
| queue.append((link, depth + 1)) | |
| if len(seen) > max_pages * 20: | |
| break | |
| except Exception: | |
| pass | |
| if not pages: | |
| raise HTTPException(502, f"no page could be crawled from {start}") | |
| return {"start_url": start, "pages_fetched": len(pages), "ok": len(pages), | |
| "failed": failures[:10], "pages": pages, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| async def site_map(req: SitemapRequest): | |
| raw_url = (req.url or "").strip() | |
| if not raw_url: | |
| raise HTTPException(400, "url is required") | |
| if not raw_url.startswith(("http://", "https://")): | |
| raw_url = "https://" + raw_url | |
| max_urls = max(1, min(int(req.max_urls or 500), 2000)) | |
| from urllib.parse import urlparse | |
| from lxml import etree | |
| p = urlparse(raw_url) | |
| site = f"{p.scheme}://{p.netloc}" | |
| # Cloudflare-fronted origins 403 the default "compatible; clean-read/1.0" UA from a | |
| # datacenter IP, which used to surface as a misleading "no sitemap found" 404 — the | |
| # buyer paid and was told the site has no sitemap when really we were blocked. | |
| # Ask as a browser; sitemaps are public documents. | |
| SITEMAP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") | |
| # 1. robots.txt Sitemap: lines, then the conventional locations. | |
| candidates = [] | |
| blocked: list = [] | |
| async def _get(url: str, cap: int, timeout: float): | |
| """Fetch, recording upstream refusals so they can be reported rather than | |
| silently collapsing into 'no sitemap'.""" | |
| try: | |
| return await _fetch_text(url, cap, timeout=timeout, ua=SITEMAP_UA) | |
| except Exception as ex: | |
| status = getattr(getattr(ex, "response", None), "status_code", None) | |
| if status in (401, 403, 405, 406, 429) or status is None: | |
| blocked.append({"url": url, "status": status, "error": type(ex).__name__}) | |
| raise | |
| try: | |
| robots, _ = await _get(site + "/robots.txt", 500_000, 15.0) | |
| for line in robots.splitlines(): | |
| if line.lower().startswith("sitemap:"): | |
| loc = line.split(":", 1)[1].strip() | |
| if loc.startswith("http") and loc not in candidates: | |
| candidates.append(loc) | |
| except Exception: | |
| pass | |
| for guess in (site + "/sitemap.xml", site + "/sitemap_index.xml"): | |
| if guess not in candidates: | |
| candidates.append(guess) | |
| urls, used, index_expansions = [], [], 0 | |
| async def load(loc: str, depth: int = 0): | |
| nonlocal index_expansions | |
| if len(urls) >= max_urls or depth > 2: | |
| return | |
| try: | |
| body, final = await _get(loc, 5_000_000, 20.0) | |
| except Exception: | |
| return | |
| try: | |
| root = etree.fromstring(body.encode("utf-8", "replace"), | |
| etree.XMLParser(recover=True, resolve_entities=False)) | |
| except Exception: | |
| return | |
| if root is None: | |
| return | |
| used.append(final) | |
| tag = etree.QName(root).localname if root.tag is not etree.Comment else "" | |
| # Match on local names instead of a hardcoded namespace URI. Plenty of real | |
| # sitemaps declare no namespace, or the http vs https form of the schema URL; | |
| # a fixed prefix silently yields 0 URLs on those, i.e. "this site has no | |
| # sitemap" for a site that plainly does. Same failure mode as the OFAC parser. | |
| def _find(el, name): | |
| return [e for e in el.iter() if isinstance(e.tag, str) | |
| and etree.QName(e).localname == name] | |
| def _child_text(el, name): | |
| for e in el: | |
| if isinstance(e.tag, str) and etree.QName(e).localname == name and e.text: | |
| return e.text.strip() | |
| return None | |
| if tag == "sitemapindex": | |
| children = [t for t in (_child_text(e, "loc") for e in _find(root, "sitemap")) if t] | |
| for child in children[:20]: | |
| index_expansions += 1 | |
| await load(child, depth + 1) | |
| return | |
| for u in _find(root, "url"): | |
| loc_text = _child_text(u, "loc") | |
| if loc_text: | |
| urls.append({"loc": loc_text, "lastmod": _child_text(u, "lastmod")}) | |
| if len(urls) >= max_urls: | |
| return | |
| for c in candidates: | |
| await load(c) | |
| if len(urls) >= max_urls: | |
| break | |
| if not urls: | |
| # Tell the buyer which of the two it is. 404 means "this site has no sitemap"; | |
| # 502 means "the origin refused us" — a paid call must not report the second | |
| # as the first. | |
| if blocked: | |
| raise HTTPException(502, { | |
| "error": f"origin refused our requests for {site}; no sitemap could be read", | |
| "blocked": blocked[:6], | |
| "hint": "the site is likely behind a bot filter that rejects datacenter IPs", | |
| }) | |
| raise HTTPException(404, f"no sitemap URLs found for {site} (tried {candidates})") | |
| return {"site": site, "sitemaps": used, "sitemap_index_expansions": index_expansions, | |
| "count": len(urls), "urls": urls, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| async def parse_feed(req: RssRequest): | |
| raw_url = (req.url or "").strip() | |
| if not raw_url: | |
| raise HTTPException(400, "url is required") | |
| if not raw_url.startswith(("http://", "https://")): | |
| raw_url = "https://" + raw_url | |
| max_items = max(1, min(int(req.max_items or 50), 200)) | |
| from lxml import etree, html as lh | |
| from urllib.parse import urljoin | |
| body, final = await _fetch_text(raw_url, 5_000_000, timeout=20.0) | |
| def as_xml(text: str): | |
| try: | |
| root = etree.fromstring(text.encode("utf-8", "replace"), | |
| etree.XMLParser(recover=True, resolve_entities=False)) | |
| except Exception: | |
| return None | |
| if root is None: | |
| return None | |
| name = etree.QName(root).localname.lower() if isinstance(root.tag, str) else "" | |
| return root if name in ("rss", "feed", "rdf") else None | |
| root = as_xml(body) | |
| if root is None: # looked like a site page → auto-discover its feed link | |
| try: | |
| doc = lh.fromstring(body) | |
| links = doc.xpath("//link[@type='application/rss+xml' or @type='application/atom+xml']") | |
| hrefs = [urljoin(final, l.get("href")) for l in links if l.get("href")] | |
| except Exception: | |
| hrefs = [] | |
| for h in hrefs[:3]: | |
| try: | |
| body2, final2 = await _fetch_text(h, 5_000_000, timeout=20.0) | |
| except Exception: | |
| continue | |
| root = as_xml(body2) | |
| if root is not None: | |
| final = final2 | |
| break | |
| if root is None: | |
| raise HTTPException(422, "not an RSS/Atom feed and no feed link could be discovered") | |
| ATOM = "http://www.w3.org/2005/Atom" | |
| def txt(el): | |
| return " ".join(el.text_content().split()) if hasattr(el, "text_content") else ( | |
| " ".join(el.text.split()) if el is not None and el.text else None) | |
| kind = etree.QName(root).localname.lower() | |
| items, feed_title = [], None | |
| if kind == "feed": # Atom | |
| t = root.find(f"{{{ATOM}}}title") | |
| feed_title = txt(t) | |
| for e in root.findall(f"{{{ATOM}}}entry")[:max_items]: | |
| link_el = e.find(f"{{{ATOM}}}link") | |
| summary = e.find(f"{{{ATOM}}}summary") | |
| if summary is None: | |
| summary = e.find(f"{{{ATOM}}}content") | |
| items.append({ | |
| "title": txt(e.find(f"{{{ATOM}}}title")), | |
| "link": link_el.get("href") if link_el is not None else None, | |
| "published": txt(e.find(f"{{{ATOM}}}updated")) or txt(e.find(f"{{{ATOM}}}published")), | |
| "summary": (txt(summary) or "")[:2000] or None, | |
| }) | |
| fmt = "atom" | |
| else: # RSS 2.0 / RDF — namespace-agnostic local-name search | |
| feed_title = txt(root.find(".//channel/title")) or txt(root.find(".//title")) | |
| entries = root.findall(".//item") or root.findall(".//{*}item") | |
| for e in entries[:max_items]: | |
| def sub(name): | |
| el = e.find(name) | |
| return el if el is not None else e.find("{*}" + name) | |
| items.append({ | |
| "title": txt(sub("title")), | |
| "link": txt(sub("link")) or (sub("guid") is not None and txt(sub("guid"))) or None, | |
| "published": txt(sub("pubDate")) or txt(sub("date")), | |
| "summary": (txt(sub("description")) or "")[:2000] or None, | |
| }) | |
| fmt = "rss" | |
| if not items: | |
| raise HTTPException(422, "feed parsed but contained no items") | |
| return {"feed_url": final, "title": feed_title, "format": fmt, | |
| "count": len(items), "items": items, | |
| "fetched_at": datetime.utcnow().isoformat() + "Z"} | |
| async def extract_structured(req: ExtractRequest): | |
| if not req.url or not req.url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| try: | |
| html, final = await _fetch_text(req.url, 2_000_000) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| import json as _json | |
| from lxml import html as lhtml | |
| try: | |
| tree = lhtml.fromstring(html) | |
| except Exception as e: | |
| raise HTTPException(422, f"could not parse HTML: {type(e).__name__}") | |
| def _txt(nodes): | |
| return [" ".join(n.text_content().split()) for n in nodes] | |
| opengraph = {} | |
| meta_desc = None | |
| for m in tree.xpath("//meta"): | |
| prop = m.get("property") or m.get("name") or "" | |
| content = m.get("content") | |
| if not content: | |
| continue | |
| if prop.startswith(("og:", "twitter:")): | |
| opengraph[prop] = content | |
| elif prop.lower() == "description": | |
| meta_desc = content | |
| jsonld = [] | |
| for s in tree.xpath('//script[@type="application/ld+json"]'): | |
| try: | |
| jsonld.append(_json.loads(s.text_content())) | |
| except Exception: | |
| continue | |
| headings = [ | |
| {"level": int(h.tag[1]), "text": t} | |
| for h, t in zip(tree.xpath("//h1|//h2|//h3"), _txt(tree.xpath("//h1|//h2|//h3"))) | |
| if t | |
| ][:100] | |
| from urllib.parse import urljoin | |
| links, seen = [], set() | |
| for a in tree.xpath("//a[@href]"): | |
| href = urljoin(final, a.get("href")) | |
| if href.startswith(("http://", "https://")) and href not in seen: | |
| seen.add(href) | |
| links.append(href) | |
| if len(links) >= 200: | |
| break | |
| titles = tree.xpath("//title/text()") | |
| return { | |
| "url": final, | |
| "title": (titles[0].strip() if titles else None), | |
| "description": meta_desc or opengraph.get("og:description"), | |
| "opengraph": opengraph, | |
| "jsonld": jsonld[:20], | |
| "headings": headings, | |
| "links": links, | |
| "link_count": len(links), | |
| "fetched_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| async def pdf_to_text(req: PdfRequest): | |
| if not req.url or not req.url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| try: | |
| from pypdf import PdfReader | |
| except ImportError: | |
| raise HTTPException(503, "pdf engine unavailable") | |
| import httpx | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=30.0) as client: | |
| resp = await _safe_get(client, req.url, ua="clean-read/1.0") | |
| resp.raise_for_status() | |
| except HTTPException: | |
| raise | |
| except httpx.HTTPStatusError as e: | |
| raise HTTPException(502, f"upstream returned {e.response.status_code}") | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| raw = resp.content | |
| if len(raw) > 25_000_000: | |
| raise HTTPException(413, "pdf too large (max 25MB)") | |
| if not raw[:5].startswith(b"%PDF"): | |
| raise HTTPException(415, "url did not return a PDF") | |
| import io | |
| try: | |
| reader = PdfReader(io.BytesIO(raw)) | |
| total = len(reader.pages) | |
| cap = max(1, min(int(req.max_pages or 50), 200)) | |
| parts = [] | |
| for i, page in enumerate(reader.pages[:cap], 1): | |
| try: | |
| body = (page.extract_text() or "").strip() | |
| except Exception: | |
| body = "" | |
| parts.append(f"# Page {i}\n\n{body}") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(422, f"could not read pdf: {type(e).__name__}: {e}") | |
| text = "\n\n".join(parts) | |
| return { | |
| "url": str(resp.url), | |
| "pages": total, | |
| "extracted_pages": min(total, cap), | |
| "text": text, | |
| "word_count": len(text.split()), | |
| "fetched_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| # HTTP response headers that reduce attack surface, with weight toward the security grade. | |
| _SEC_HEADERS = { | |
| "strict-transport-security": 25, | |
| "content-security-policy": 25, | |
| "x-content-type-options": 15, | |
| "x-frame-options": 15, | |
| "referrer-policy": 10, | |
| "permissions-policy": 10, | |
| } | |
| import re as _re | |
| # High-precision web-scam / phishing / wallet-drainer signals. Tuned to NOT fire on | |
| # normal sites (github, docs, blogs) — unlike the AI-skill scanner, which over-matches | |
| # on ordinary HTML. Each hit carries a severity weight subtracted from content_safety. | |
| _SCAM_RULES = [ | |
| ("seed_phrase_harvest", 60, _re.compile( | |
| r"(seed phrase|recovery phrase|mnemonic|private key|secret recovery)", _re.I)), | |
| ("wallet_drainer", 55, _re.compile( | |
| r"(setApprovalForAll|permit2|signTypedData_v4[^a-z]|eth_sign\b|sweepToken|drainer|_transferFrom.*unlimited)", _re.I)), | |
| ("fake_airdrop", 30, _re.compile( | |
| r"(claim (your )?(free )?(airdrop|reward|token|nft)|you'?ve won|verify (your )?wallet to (claim|continue|unlock)|connect wallet to claim)", _re.I)), | |
| ("credential_phish", 35, _re.compile( | |
| r"<input[^>]+type=[\"']?password", _re.I)), | |
| ("obfuscated_js", 25, _re.compile( | |
| r"(eval\(\s*(atob|unescape|String\.fromCharCode)|document\.write\(\s*unescape|Function\(\s*[\"']return|\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2})", _re.I)), | |
| ("meta_redirect", 15, _re.compile( | |
| r"<meta[^>]+http-equiv=[\"']?refresh[\"']?[^>]+url=https?://", _re.I)), | |
| ] | |
| def _web_scam_scan(html: str, final_url: str): | |
| """Return (score 0-100, [signals]) — high-precision web-scam detection for /trust.""" | |
| signals, penalty = [], 0 | |
| for name, weight, rx in _SCAM_RULES: | |
| m = rx.search(html) | |
| if not m: | |
| continue | |
| # seed/private-key phrasing only counts as harvesting if there's an input to type into | |
| if name == "seed_phrase_harvest" and "<input" not in html.lower() and "<textarea" not in html.lower(): | |
| continue | |
| # a password field over plaintext http is the real phishing tell; over https it's normal login | |
| if name == "credential_phish" and final_url.startswith("https://"): | |
| weight = 10 | |
| signals.append({"signal": name, "severity": "high" if weight >= 45 else ("medium" if weight >= 25 else "low"), | |
| "match": (m.group(0)[:60])}) | |
| penalty += weight | |
| return max(0, 100 - penalty), signals | |
| def _parse_x402_challenge(resp): | |
| """Extract the x402 payment requirements from a 402 response. v2 puts a base64 JSON | |
| in the `payment-required` header; v1 used `www-authenticate`; some servers use the body.""" | |
| import base64, json as _json | |
| raw = None | |
| for h in ("payment-required", "www-authenticate", "x-payment-required"): | |
| v = resp.headers.get(h) | |
| if v: | |
| token = v.split(" ", 1)[-1].strip() if " " in v and not v.strip().startswith("ey") else v.strip() | |
| try: | |
| raw = _json.loads(base64.b64decode(token + "===")) | |
| break | |
| except Exception: | |
| try: | |
| raw = _json.loads(token) | |
| break | |
| except Exception: | |
| continue | |
| if raw is None: | |
| try: | |
| b = resp.json() | |
| if isinstance(b, dict) and (b.get("accepts") or b.get("x402Version")): | |
| raw = b | |
| except Exception: | |
| raw = None | |
| return raw | |
| async def _doh_query(client, name: str, rtype: str): | |
| """One DNS-over-HTTPS query via Cloudflare. Returns list of record data strings.""" | |
| try: | |
| r = await client.get( | |
| "https://cloudflare-dns.com/dns-query", | |
| params={"name": name, "type": rtype}, | |
| headers={"accept": "application/dns-json"}, | |
| ) | |
| if r.status_code != 200: | |
| return [] | |
| return [a["data"].strip('"') for a in r.json().get("Answer", []) if a.get("data")] | |
| except Exception: | |
| return [] | |
| async def trust_score(req: TrustRequest): | |
| """Composite, transparent, x402-native trust score for an arbitrary URL / x402 server. | |
| Beats single-signal scorers: one call returns transport + content-safety + domain + | |
| metadata + x402-compliance sub-scores, each backed by real evidence.""" | |
| _validate_public_url(req.url) # SSRF guard | |
| import httpx, time | |
| from urllib.parse import urlparse | |
| evidence, subs = {}, {} | |
| reachable = False | |
| body, final_url, hdrs = "", req.url, {} | |
| # ── reachability + transport (timed GET) ── | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=15.0) as client: | |
| t0 = time.perf_counter() | |
| resp = await _safe_get(client, req.url, ua="skill-audit/2.2 (+trust-scan)") | |
| evidence["latency_ms"] = int((time.perf_counter() - t0) * 1000) | |
| reachable = resp.status_code < 500 | |
| final_url = str(resp.url) | |
| hdrs = {k.lower(): v for k, v in resp.headers.items()} | |
| body = resp.text[:1_000_000] | |
| evidence["status"] = resp.status_code | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| evidence["fetch_error"] = f"{type(e).__name__}: {e}" | |
| # transport: HTTPS + security headers | |
| https = final_url.startswith("https://") | |
| present = [h for h in _SEC_HEADERS if h in hdrs] | |
| missing = [h for h in _SEC_HEADERS if h not in hdrs] | |
| hdr_pts = sum(_SEC_HEADERS[h] for h in present) # 0..100 | |
| subs["transport"] = (60 if https else 0) + int(hdr_pts * 0.4) if reachable else 0 | |
| evidence["https"] = https | |
| evidence["present_headers"] = present | |
| evidence["missing_headers"] = missing | |
| # ── content safety (high-precision web-scam/phishing/drainer scan) ── | |
| if body: | |
| cs_score, scam_signals = _web_scam_scan(body, final_url) | |
| subs["content_safety"] = cs_score | |
| evidence["scam_signals"] = scam_signals | |
| evidence["malicious_findings"] = len(scam_signals) | |
| else: | |
| subs["content_safety"] = 0 if not reachable else 100 | |
| evidence["malicious_findings"] = 0 | |
| # ── metadata quality (real content vs parked/empty) ── | |
| title = None | |
| word_count = 0 | |
| try: | |
| from lxml import html as lhtml | |
| tree = lhtml.fromstring(body) if body else None | |
| if tree is not None: | |
| t = tree.xpath("//title/text()") | |
| title = t[0].strip() if t else None | |
| og = any((m.get("property") or "").startswith("og:") for m in tree.xpath("//meta")) | |
| word_count = len(tree.text_content().split()) | |
| q = 0 | |
| if title: | |
| q += 40 | |
| if og: | |
| q += 25 | |
| if word_count >= 50: | |
| q += 35 | |
| elif word_count >= 10: | |
| q += 15 | |
| subs["metadata"] = min(100, q) | |
| evidence["title"] = title | |
| evidence["word_count"] = word_count | |
| else: | |
| subs["metadata"] = 0 | |
| except Exception: | |
| subs["metadata"] = 30 if body else 0 | |
| # ── domain trust (DNS + SPF/DMARC) ── | |
| host = urlparse(final_url).hostname or "" | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| a = await _doh_query(client, host, "A") | |
| aaaa = await _doh_query(client, host, "AAAA") | |
| mx = await _doh_query(client, host, "MX") | |
| txt = await _doh_query(client, host, "TXT") | |
| dmarc = await _doh_query(client, f"_dmarc.{host}", "TXT") | |
| has_spf = any("v=spf1" in t.lower() for t in txt) | |
| has_dmarc = any("v=dmarc1" in t.lower() for t in dmarc) | |
| d = 0 | |
| if a or aaaa: | |
| d += 45 | |
| if mx: | |
| d += 15 | |
| if has_spf: | |
| d += 20 | |
| if has_dmarc: | |
| d += 20 | |
| subs["domain"] = min(100, d) | |
| evidence.update({"has_a": bool(a or aaaa), "has_mx": bool(mx), "spf": has_spf, "dmarc": has_dmarc}) | |
| except Exception: | |
| subs["domain"] = 40 | |
| # ── x402 compliance (the question buyer-agents actually have) ── | |
| x402_info = {"is_x402": False, "valid_challenge": False} | |
| if req.check_x402: | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=12.0) as client: | |
| _validate_public_url(req.url) | |
| pr = await client.post(req.url, json={}, headers={"User-Agent": "skill-audit/2.2"}) | |
| if pr.status_code == 402: | |
| x402_info["is_x402"] = True | |
| ch = _parse_x402_challenge(pr) | |
| if ch: | |
| accepts = ch.get("accepts") or (ch.get("error", {}) if isinstance(ch.get("error"), dict) else {}).get("accepts") or [] | |
| opt = accepts[0] if accepts else {} | |
| pay_to = opt.get("payTo") or opt.get("pay_to") | |
| network = opt.get("network") | |
| asset = (opt.get("extra") or {}).get("name") or opt.get("asset") | |
| x402_info["valid_challenge"] = bool(pay_to and network) | |
| x402_info.update({"pay_to": pay_to, "network": network, "asset": asset, | |
| "amount": opt.get("amount"), | |
| "x402_version": ch.get("x402Version") or ch.get("x402_version")}) | |
| subs["x402"] = 100 if x402_info["valid_challenge"] else (50 if x402_info["is_x402"] else 0) | |
| except Exception: | |
| subs["x402"] = 0 | |
| else: | |
| subs.pop("x402", None) | |
| # ── aggregate (weighted) ── | |
| weights = {"transport": 0.20, "content_safety": 0.30, "domain": 0.20, "metadata": 0.10, "x402": 0.20} | |
| active = {k: v for k, v in subs.items() if k in weights} | |
| wsum = sum(weights[k] for k in active) or 1 | |
| trust = round(sum(active[k] * weights[k] for k in active) / wsum) | |
| critical_content = evidence.get("malicious_findings", 0) > 0 and subs.get("content_safety", 100) < 50 | |
| is_scam = (not reachable) or critical_content or (trust < 30) | |
| if trust >= 80: | |
| level = "low" | |
| elif trust >= 60: | |
| level = "medium" | |
| elif trust >= 35: | |
| level = "high" | |
| else: | |
| level = "critical" | |
| bits = [] | |
| bits.append("reachable" if reachable else "UNREACHABLE") | |
| bits.append("HTTPS" if https else "no-HTTPS") | |
| bits.append(f"{evidence.get('malicious_findings', 0)} malicious pattern(s)") | |
| if req.check_x402: | |
| bits.append("valid x402 challenge" if x402_info["valid_challenge"] | |
| else ("x402 402 but malformed" if x402_info["is_x402"] else "not an x402 endpoint")) | |
| verdict = ", ".join(bits) + "." | |
| return { | |
| "url": final_url, | |
| "trust_score": trust, | |
| "risk_level": level, | |
| "is_scam": is_scam, | |
| "verdict": verdict, | |
| "sub_scores": active, | |
| "x402": x402_info if req.check_x402 else None, | |
| "evidence": evidence, | |
| "scored_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| async def security_headers(req: HeadersRequest): | |
| if not req.url or not req.url.startswith(("http://", "https://")): | |
| raise HTTPException(400, "valid http/https URL required") | |
| import httpx | |
| try: | |
| async with httpx.AsyncClient(follow_redirects=False, timeout=15.0) as client: | |
| resp = await _safe_get(client, req.url, ua="skill-audit/2.1 (+security-headers)") | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| hdrs = {k.lower(): v for k, v in resp.headers.items()} | |
| present, missing, score = [], [], 0 | |
| for h, w in _SEC_HEADERS.items(): | |
| if h in hdrs: | |
| present.append(h) | |
| score += w | |
| else: | |
| missing.append(h) | |
| grade = ("A" if score >= 90 else "B" if score >= 70 else "C" if score >= 50 | |
| else "D" if score >= 25 else "F") | |
| return { | |
| "url": str(resp.url), | |
| "status": resp.status_code, | |
| "score": score, | |
| "grade": grade, | |
| "present": present, | |
| "missing": missing, | |
| "headers": {h: hdrs[h] for h in present}, | |
| "server": hdrs.get("server"), | |
| "checked_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| async def dns_lookup(req: DnsRequest): | |
| domain = (req.domain or "").strip() | |
| if domain.startswith(("http://", "https://")): | |
| from urllib.parse import urlparse | |
| domain = urlparse(domain).hostname or domain | |
| domain = domain.rstrip(".").lower() | |
| if not domain or "." not in domain or " " in domain: | |
| raise HTTPException(400, "valid domain required") | |
| import httpx | |
| types = ["A", "AAAA", "MX", "NS", "TXT", "CNAME"] | |
| records = {} | |
| try: | |
| async with httpx.AsyncClient(timeout=12.0) as client: | |
| for rt in types: | |
| r = await client.get( | |
| "https://cloudflare-dns.com/dns-query", | |
| params={"name": domain, "type": rt}, | |
| headers={"accept": "application/dns-json"}, | |
| ) | |
| ans = r.json().get("Answer", []) if r.status_code == 200 else [] | |
| vals = [a["data"].strip('"') for a in ans if a.get("type") is not None] | |
| records[rt] = vals | |
| except Exception as e: | |
| raise HTTPException(502, f"dns query failed: {type(e).__name__}") | |
| txt = " ".join(records.get("TXT", [])) | |
| flags = { | |
| "has_a": bool(records.get("A") or records.get("AAAA")), | |
| "has_mx": bool(records.get("MX")), | |
| "has_spf": "v=spf1" in txt.lower(), | |
| "has_dmarc": False, # filled below | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| r = await client.get("https://cloudflare-dns.com/dns-query", | |
| params={"name": f"_dmarc.{domain}", "type": "TXT"}, | |
| headers={"accept": "application/dns-json"}) | |
| dmarc = " ".join(a["data"].strip('"') for a in r.json().get("Answer", [])) if r.status_code == 200 else "" | |
| flags["has_dmarc"] = "v=dmarc1" in dmarc.lower() | |
| except Exception: | |
| pass | |
| return { | |
| "domain": domain, | |
| "records": records, | |
| "flags": flags, | |
| "resolved_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| # Text-ish files worth scanning inside a repo tarball; everything else is skipped. | |
| _SCAN_EXT = { | |
| ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".sh", ".bash", ".zsh", | |
| ".rb", ".go", ".rs", ".php", ".pl", ".ps1", ".md", ".txt", ".json", ".yaml", | |
| ".yml", ".toml", ".cfg", ".ini", ".env", ".sql", ".java", ".c", ".cpp", ".h", | |
| } | |
| # Tests, docs and changelogs quote attack strings on purpose, so scanning them | |
| # buries the real signal (psf/requests scored 100/high entirely on test fixtures). | |
| _NOISE_DIRS = ("test/", "tests/", "docs/", "doc/", "examples/", "example/", | |
| "fixtures/", "testdata/", "spec/", "__tests__/", "benchmarks/") | |
| _NOISE_NAMES = ("changelog", "history", "news", "releases", "release-notes") | |
| def _is_noise(path: str) -> bool: | |
| low = path.lower() | |
| if any(seg in low for seg in _NOISE_DIRS) or low.startswith(_NOISE_DIRS): | |
| return True | |
| base = low.rsplit("/", 1)[-1] | |
| if base.startswith("test_") or base.startswith("test.") or "_test." in base or ".spec." in base: | |
| return True | |
| return any(base.startswith(n) for n in _NOISE_NAMES) | |
| _LEVEL_ORDER = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "SAFE": 0, "UNKNOWN": 0} | |
| # The three entry points that actually exist on a live MCP-server repo. Probed concurrently | |
| # first, so a delisted repo costs three parallel requests rather than fourteen serial 404s. | |
| _REG_PROBE = ("package.json", "README.md", "src/index.ts") | |
| async def _reg_fetch(client, owner, name, path): | |
| """One raw.githubusercontent read; returns the body or None. Never raises.""" | |
| try: | |
| r = await client.get(f"https://raw.githubusercontent.com/{owner}/{name}/HEAD/{path}") | |
| except Exception: # noqa: BLE001 | |
| return None | |
| return r.text if r.status_code == 200 and len(r.text) >= 40 else None | |
| _REG_CANDIDATES = ("package.json", "setup.py", "pyproject.toml", "install.sh", | |
| "postinstall.js", "src/index.ts", "index.ts", "src/index.js", "index.js", | |
| "server.py", "src/main.py", "main.py", "app.py", "README.md") | |
| async def audit_registry(req: RegistryAuditRequest): | |
| """Scan the newest servers in the official MCP registry. No input required. | |
| See RegistryAuditRequest for why this exists: every route that needs an argument from the | |
| caller converts ~25x worse than one that can be called blind, because the buyers are | |
| crawlers. This gives a blind caller a real answer — a fresh supply-chain risk feed over | |
| servers somebody just published — using the same pattern engine as /audit. | |
| """ | |
| import asyncio | |
| from datetime import timedelta, timezone | |
| import httpx | |
| n = max(1, min(int(req.limit or 5), 15)) | |
| # ⚠ `sort=newest` is accepted and SILENTLY IGNORED — it returns the same alphabetical | |
| # first page. `updated_since` genuinely filters, so recency comes from that. Without it | |
| # the first page is dominated by long-dead entries: a plain limit=5 scan returned four | |
| # UNKNOWNs out of five, which is not worth $0.005 to anybody. | |
| since = (datetime.now(timezone.utc) - timedelta(days=45)).strftime("%Y-%m-%dT%H:%M:%SZ") | |
| async with httpx.AsyncClient(follow_redirects=True, timeout=25.0, | |
| headers={"User-Agent": "x402-registry-audit"}) as client: | |
| try: | |
| r = await client.get("https://registry.modelcontextprotocol.io/v0/servers", | |
| params={"limit": 100, "updated_since": since}) | |
| servers = (r.json() or {}).get("servers", []) | |
| except Exception as e: # noqa: BLE001 | |
| raise HTTPException(502, f"registry unreachable: {type(e).__name__}") from None | |
| seen, targets = set(), [] | |
| for entry in servers: | |
| srv = entry.get("server", entry) | |
| official = (entry.get("_meta") or {}).get( | |
| "io.modelcontextprotocol.registry/official") or {} | |
| if official.get("status") not in (None, "active"): | |
| continue | |
| url = ((srv.get("repository") or {}).get("url") or "") | |
| if "github.com/" not in url: | |
| continue | |
| parts = [p for p in url.split("github.com/", 1)[1].split("/") if p][:2] | |
| if len(parts) != 2: | |
| continue | |
| key = (parts[0].lower(), parts[1].removesuffix(".git").lower()) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| targets.append((parts[0], parts[1].removesuffix(".git"), srv.get("name", ""), | |
| official.get("publishedAt"))) | |
| # Over-fetch: roughly half the registry's repos are 404 (measured), and a buyer | |
| # paying per call wants N READABLE rows, not N rows of "gone". | |
| if len(targets) >= n * 4: | |
| break | |
| rows, delisted = [], 0 | |
| for owner, name, sname, published in targets: | |
| if len(rows) >= n: | |
| break | |
| worst, score, findings, files = "SAFE", 0, [], [] | |
| def _absorb(path, text): | |
| nonlocal worst, score | |
| files.append(path) | |
| res = scan(text[:200_000]) | |
| score = max(score, res.get("risk_score") or 0) | |
| for f in res.get("findings", []): | |
| findings.append({**f, "file": path}) | |
| if _LEVEL_ORDER.get(res["risk_level"], 0) > _LEVEL_ORDER.get(worst, 0): | |
| worst = res["risk_level"] | |
| # About half of these repos are 404. Walking all 14 candidates on a dead one costs | |
| # 14 round trips and dominated the call — five results took 37s. Probe the three | |
| # most common entry points CONCURRENTLY and abandon the repo if none answers: | |
| # a dead repo now costs 3 parallel requests instead of 14 serial ones. | |
| probed = await asyncio.gather(*[_reg_fetch(client, owner, name, p) | |
| for p in _REG_PROBE]) | |
| alive = [(p, t) for p, t in zip(_REG_PROBE, probed) if t] | |
| if not alive: | |
| # "we could not read it" is never "it is safe" — but it is also not worth a | |
| # paid row. Counted, named, and skipped so the buyer gets N real answers. | |
| delisted += 1 | |
| continue | |
| for path, text in alive: | |
| _absorb(path, text) | |
| for path in _REG_CANDIDATES: | |
| if len(files) >= 4: # budget: 4 files a repo keeps the call responsive | |
| break | |
| if path in _REG_PROBE: | |
| continue | |
| text = await _reg_fetch(client, owner, name, path) | |
| if text: | |
| _absorb(path, text) | |
| rows.append({ | |
| "server": sname, "repo": f"{owner}/{name}", | |
| "repo_url": f"https://github.com/{owner}/{name}", | |
| "published_at": published, | |
| "risk_level": worst, "risk_score": score, | |
| "finding_count": len(findings), | |
| "files_scanned": files, | |
| "top_finding": (max(findings, key=lambda f: _LEVEL_ORDER.get(f["severity"], 0)) | |
| if findings else None), | |
| }) | |
| rows.sort(key=lambda r: -_LEVEL_ORDER.get(r["risk_level"], 0)) | |
| return { | |
| "source": "registry.modelcontextprotocol.io/v0/servers", | |
| "window": f"servers updated since {since}", | |
| "scanned": len(rows), | |
| "flagged": sum(1 for r in rows if r["risk_level"] != "SAFE"), | |
| # Not padding, and not hidden: about half the registry's repositories 404, and how many | |
| # were skipped to reach N readable ones is itself a fact about the registry. | |
| "delisted_skipped": delisted, | |
| "results": rows, | |
| } | |
| async def audit_repo(req: RepoAuditRequest): | |
| slug = (req.repo or "").strip() | |
| if slug.startswith(("http://", "https://")): | |
| from urllib.parse import urlparse | |
| slug = urlparse(slug).path.strip("/") | |
| slug = slug.removesuffix(".git") | |
| parts = [p for p in slug.split("/") if p] | |
| if len(parts) != 2: | |
| raise HTTPException(400, "repo must be 'owner/name' or a github.com repo URL") | |
| owner, name = parts | |
| import httpx | |
| refs = [req.ref] if req.ref else ["HEAD", "main", "master"] | |
| raw = used_ref = None | |
| async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client: | |
| for ref in refs: | |
| url = f"https://codeload.github.com/{owner}/{name}/tar.gz/{ref}" | |
| try: | |
| resp = await client.get(url, headers={"User-Agent": "skill-audit/2.1"}) | |
| except Exception as e: | |
| raise HTTPException(502, f"fetch failed: {type(e).__name__}") | |
| if resp.status_code == 200: | |
| raw, used_ref = resp.content, ref | |
| break | |
| if raw is None: | |
| raise HTTPException(404, f"could not download {owner}/{name} (private, missing, or bad ref)") | |
| if len(raw) > 60_000_000: | |
| raise HTTPException(413, "repo tarball too large (max 60MB)") | |
| import io, tarfile | |
| flagged, scanned, skipped, worst, findings_total, noise_skipped = [], 0, 0, 0, 0, 0 | |
| try: | |
| tar = tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") | |
| except Exception as e: | |
| raise HTTPException(422, f"could not open tarball: {type(e).__name__}") | |
| for member in tar: | |
| if not member.isfile() or member.size > 1_000_000: | |
| skipped += 1 | |
| continue | |
| path = member.name.split("/", 1)[-1] # strip the '{name}-{ref}/' prefix | |
| ext = "." + path.rsplit(".", 1)[-1].lower() if "." in path.rsplit("/", 1)[-1] else "" | |
| if ext not in _SCAN_EXT: | |
| skipped += 1 | |
| continue | |
| if not req.include_tests and _is_noise(path): | |
| noise_skipped += 1 | |
| continue | |
| if scanned >= 1500: | |
| skipped += 1 | |
| continue | |
| try: | |
| data = tar.extractfile(member).read().decode("utf-8", "replace") | |
| except Exception: | |
| skipped += 1 | |
| continue | |
| scanned += 1 | |
| result = scan(data) | |
| if result["total_findings"]: | |
| findings_total += result["total_findings"] | |
| worst = max(worst, result["risk_score"]) | |
| flagged.append({ | |
| "file": path, | |
| "risk_score": result["risk_score"], | |
| "risk_level": result["risk_level"], | |
| "total_findings": result["total_findings"], | |
| "findings": result["findings"][:10], | |
| }) | |
| tar.close() | |
| flagged.sort(key=lambda f: -f["risk_score"]) | |
| level = "clean" if worst == 0 else ("low" if worst < 30 else ("medium" if worst < 60 else "high")) | |
| return { | |
| "repo": f"{owner}/{name}", | |
| "ref": used_ref, | |
| "files_scanned": scanned, | |
| "files_skipped": skipped, | |
| "test_doc_files_excluded": noise_skipped, | |
| "note": ("tests/docs/changelogs excluded (they quote attack strings deliberately); " | |
| "pass include_tests=true to scan them"), | |
| "risk_score": worst, | |
| "risk_level": level, | |
| "total_findings": findings_total, | |
| "flagged_files": flagged[:50], | |
| "scanned_at": datetime.utcnow().isoformat() + "Z", | |
| } | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # GET on a paid route: advertise the price instead of 405 | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Measured on tokenguard's access log 2026-07-29: 22% of all traffic was a GET or | |
| # HEAD on a paid POST path answered with `{"detail":"Method Not Allowed"}`, which | |
| # tells a probing agent nothing about price, payTo or x402 support. Runs LAST so it | |
| # sees every route, and outside the x402 init block so it can never disable payments. | |
| if _x402_available: | |
| try: | |
| import x402_get_challenge as _getadv | |
| _getadv.install(app, routes) | |
| except Exception as _e: # pragma: no cover | |
| print(f" x402 GET-advertise unavailable: {type(_e).__name__}: {_e}") | |
| # Same gap as contract-guard: the 402 body is `{}` and the challenge lives only in the | |
| # header, so a body-reading client sees nothing to buy. Added LAST so it wraps the | |
| # payment middleware and sees the final response. | |
| try: | |
| import x402_402_body as _fillbody | |
| _fillbody.install(app) | |
| except Exception as _e: # pragma: no cover | |
| print(f" x402 GET-advertise unavailable: {type(_e).__name__}: {_e}") | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # Entry | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.environ.get("PORT", 8402)) | |
| print(f"\n skill-audit API (x402 v2) starting on :{port}") | |
| print(f" x402: {'ENABLED' if _x402_available else 'DISABLED (pip install x402[fastapi,evm,extensions])'}") | |
| print(f" Facilitator: {FACILITATOR_URL}") | |
| print(f" Wallet: {WALLET}\n") | |
| uvicorn.run(app, host="0.0.0.0", port=port) | |