Add-on — Attack-Surface Self-Scan

An owner-gated active scan of a customer's own domain — a curated common-port sweep plus a structural exposed-file probe — where the DNS-TXT ownership gate lives inside the scanner so it can't be bypassed.

Add-on — Attack-Surface Self-Scan

An owner-gated active scan of a customer's own domain: a curated common-port sweep plus a curated exposed-file/status-page probe. Patterns were harvested from the MIT Raccoon recon tool; nmap, wordlist fuzzing, and S3 brute-force were dropped in favour of a small structural probe set. No upstream code was copied and no raccoon identifier ships.

  • id / entitlement: attack_surface — granted at business+.
  • Code: lib/addons/attack-surface/. Import from @/lib/addons/attack-surface.
  • Reuses: the ssrf guard (safeFetch + resolve-and-pin on the port sweep) and lib/dns/resolver.

The passive tier (free SuperTool commands subdomains, fingerprint) also came from Raccoon but lives in lib/dns/ with no ownership gate — see DNS Tools. This doc covers the active gated add-on.

Ownership is proven in DNS, and the gate lives in the scanner

The scan will not run against a host the caller hasn't proven they own. Ownership is a DNS-TXT proof, and the enforcement lives inside runSelfScan (the route only supplies the proof), so no future caller can forget it.

  • TokenscanTokenForLicense(licenseKey) (token.ts) derives a stable, unguessable token: HMAC-SHA256(secret, "attack-surface:" + licenseKey), base64url, truncated to 32 chars. The secret is HOSTSSH_SCAN_TOKEN_SECRET, falling back to HOSTSSH_ENC_KEY. The token is derived, never stored. scanTokenConfigured() is false when neither env var is set (routes then 503).
  • RecordscanTxtRecord(domain, token) returns { name: '_hostssh-scan.<domain>', type: 'TXT', value: 'hostssh-scan=<token>' }. One token, published under each domain you want scannable (Search-Console-style).
  • VerifyverifyScanOwnership(domain, token, opts?) (ownership.ts) resolves TXT at _hostssh-scan.<domain> (5 s timeout, injectable resolver) and checks any record exactly equals hostssh-scan=<token>. Returns an OwnershipProof { domain, verified, method: 'dns-txt'|'none', detail }.

Inside runSelfScan, a scan is refused unless ownership.verified === true and the target host is proof.domain or a subdomain of it — a proof for one domain can't scan another.

What it probes

runSelfScan(target, { ownership, ... }) (scan.ts) returns a SuperTool ToolResult. Concurrency defaults to 8.

Curated port sweep — 16 common ports, each via a TCP connect returning a PortState of open | closed | filtered (3 s connect timeout). The host is resolved and pinned to a public IP first (same SSRF ranges as the fetch guard), so an owner-verified domain that points inward can't turn the sweep into an internal scanner.

PortServicePortService
21FTP3000Dev/Node
22SSH3306MySQL ⚠
23Telnet ⚠5432PostgreSQL ⚠
25SMTP6379Redis ⚠
80HTTP8080HTTP-alt
110POP38443HTTPS-alt
143IMAP9200Elasticsearch ⚠
443HTTPS27017MongoDB ⚠

An open port on the ⚠ set (23, 3306, 5432, 6379, 9200, 27017) is a fail finding; other open ports are info.

Curated exposed-file probe — each path is fetched via safeFetch and only counts when a structural signal matches (a bare HTTP 200 never counts, so ordinary pages don't false-positive):

PathFindingSeverity
/.git/HEADExposed .git repositoryfail
/.git/configExposed .git configfail
/.envExposed .env filefail
/.env.backupExposed .env.backupfail
/wp-config.php.bakExposed wp-config backupfail
/phpinfo.phpExposed phpinfo()fail
/server-statusApache server-status openwarn
/.DS_StoreExposed .DS_Storewarn
/backup.zipExposed backup.zipwarn
/config.jsonExposed config.json with secretswarn

Signals are matched against how the real file starts or its syntax — e.g. /.git/HEAD requires a ref: refs/… symref or a bare 40-hex SHA; /.env requires KEY=value (after optional comments); /phpinfo.php requires both a <title>phpinfo()</title> and a PHP Version cell; /server-status requires "Apache Server Status for" and a scoreboard token. (The dual-marker requirement for phpinfo / server-status is the false-positive fix from the attack-surface remediation.)

False-positive defences: the scanner probes a nonexistent path first to detect a soft-404 catch-all (findings whose body matches the baseline are suppressed; low-confidence signals like /config.json are dropped entirely on soft-404 hosts), and rawFile paths that return text/html are treated as rendered pages, not files. If a CDN/WAF is detected in the response headers (Cloudflare / Sucuri / Akamai / Fastly), a caveat is added that ports/paths reflect the edge, not necessarily the origin.

The result carries ok, meta (Host, Ownership, open-port and exposed-path counts), a checks list (ownership-verified pass, port/path findings, caveats), and tables (all 16 ports; exposed paths if any). runSelfScan returns ok: false when the ownership gate refuses — the route turns that into a 403.

HTTP routes

Both are license-bearer gated (agentGate) and requireAddon(key, 'attack-surface'); both 503 when scanTokenConfigured() is false.

Method · PathRequestResponse
GET /api/v1/attack-surface?domain=<domain>{ domain, token, record, verified, detail } — the TXT record to publish + current verification status
POST /api/v1/attack-surface/scan{ target: "<hostname>" } (tighter rate limit)the scan ToolResult; 403 when the target isn't verified (authorization), so "not allowed" is distinct from "scanned, nothing found"

attack-surface/route.ts · attack-surface/scan/route.ts. The scan route recomputes the caller's token, confirms it via verifyScanOwnership, and passes the proof into runSelfScan — it never bypasses the in-scanner gate.

Operator UI

Server actions in actions.ts power an "Attack surface" section on the DNS-tools god page (ResultView-rendered). They are session + dns.use gated, also require scanTokenConfigured(), and derive the token from the operator principal session.admin.id (not a license key): attackSurfaceTokenAction(domain) returns the record + verification status (10 s verify timeout); attackSurfaceScanAction(target) verifies then runs the scan (10 s verify + 30 s scan timeout).

Honest status notes

  • Scanning is fully disabled unless HOSTSSH_SCAN_TOKEN_SECRET or HOSTSSH_ENC_KEY is set — every route/action 503s with code: 'not_configured'.
  • Dropped from Raccoon: nmap, wordlist fuzzing, S3 bucket brute-force, email harvesting (GDPR/spam liability), and the OWASP active vuln scanner. This is a self-scan, not a pentest SaaS.
  • An optional S3-misconfig probe (a separate bucket-URL scanner, not a host-relative path set) remains a possible follow-up.