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 atbusiness+. - Code:
lib/addons/attack-surface/. Import from@/lib/addons/attack-surface. - Reuses: the
ssrfguard (safeFetch+ resolve-and-pin on the port sweep) andlib/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.
- Token —
scanTokenForLicense(licenseKey)(token.ts) derives a stable, unguessable token:HMAC-SHA256(secret, "attack-surface:" + licenseKey), base64url, truncated to 32 chars. The secret isHOSTSSH_SCAN_TOKEN_SECRET, falling back toHOSTSSH_ENC_KEY. The token is derived, never stored.scanTokenConfigured()is false when neither env var is set (routes then503). - Record —
scanTxtRecord(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). - Verify —
verifyScanOwnership(domain, token, opts?)(ownership.ts) resolves TXT at_hostssh-scan.<domain>(5 s timeout, injectable resolver) and checks any record exactly equalshostssh-scan=<token>. Returns anOwnershipProof{ 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.
| Port | Service | Port | Service |
|---|---|---|---|
| 21 | FTP | 3000 | Dev/Node |
| 22 | SSH | 3306 | MySQL ⚠ |
| 23 | Telnet ⚠ | 5432 | PostgreSQL ⚠ |
| 25 | SMTP | 6379 | Redis ⚠ |
| 80 | HTTP | 8080 | HTTP-alt |
| 110 | POP3 | 8443 | HTTPS-alt |
| 143 | IMAP | 9200 | Elasticsearch ⚠ |
| 443 | HTTPS | 27017 | MongoDB ⚠ |
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):
| Path | Finding | Severity |
|---|---|---|
/.git/HEAD | Exposed .git repository | fail |
/.git/config | Exposed .git config | fail |
/.env | Exposed .env file | fail |
/.env.backup | Exposed .env.backup | fail |
/wp-config.php.bak | Exposed wp-config backup | fail |
/phpinfo.php | Exposed phpinfo() | fail |
/server-status | Apache server-status open | warn |
/.DS_Store | Exposed .DS_Store | warn |
/backup.zip | Exposed backup.zip | warn |
/config.json | Exposed config.json with secrets | warn |
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 · Path | Request | Response |
|---|---|---|
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_SECRETorHOSTSSH_ENC_KEYis set — every route/action503s withcode: '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.