|
| 1 | +import psl from 'psl'; |
| 2 | +import middleware from './_common/middleware.js'; |
| 3 | +import { httpGet } from './_common/http.js'; |
| 4 | +import { parseTarget } from './_common/parse-target.js'; |
| 5 | +import { upstreamError } from './_common/upstream.js'; |
| 6 | + |
| 7 | +const MAX_SUBDOMAINS = 500; |
| 8 | +const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; |
| 9 | + |
| 10 | +// Reduce a hostname to its registrable domain so we search the whole zone |
| 11 | +const baseDomain = (host) => psl.parse(host)?.domain || host; |
| 12 | + |
| 13 | +// Skip raw IPs, since CT logs are indexed by hostname not address |
| 14 | +const isIpAddress = (host) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':'); |
| 15 | + |
| 16 | +// Flatten crt.sh rows into a clean, deduped list of valid subdomains under the base |
| 17 | +const collectSubdomains = (rows, base) => { |
| 18 | + const suffix = `.${base}`; |
| 19 | + const out = new Set(); |
| 20 | + for (const row of rows) { |
| 21 | + const raw = row?.name_value; |
| 22 | + if (typeof raw !== 'string') continue; |
| 23 | + for (const part of raw.split('\n')) { |
| 24 | + const name = part.trim().toLowerCase().replace(/^\*\./, ''); |
| 25 | + if (!name || name === base) continue; |
| 26 | + if (!name.endsWith(suffix)) continue; |
| 27 | + if (!HOSTNAME_RE.test(name)) continue; |
| 28 | + out.add(name); |
| 29 | + } |
| 30 | + } |
| 31 | + return [...out].sort(); |
| 32 | +}; |
| 33 | + |
| 34 | +const subdomainsHandler = async (url) => { |
| 35 | + const { hostname } = parseTarget(url); |
| 36 | + if (isIpAddress(hostname)) { |
| 37 | + return { skipped: 'Subdomain enumeration only applies to domain names' }; |
| 38 | + } |
| 39 | + const domain = baseDomain(hostname); |
| 40 | + if (!domain || !domain.includes('.')) { |
| 41 | + return { skipped: 'Could not resolve a registrable domain' }; |
| 42 | + } |
| 43 | + try { |
| 44 | + const res = await httpGet('https://crt.sh/', { |
| 45 | + params: { q: `%.${domain}`, output: 'json' }, |
| 46 | + headers: { Accept: 'application/json' }, |
| 47 | + }); |
| 48 | + const rows = Array.isArray(res.data) ? res.data : []; |
| 49 | + const all = collectSubdomains(rows, domain); |
| 50 | + if (!all.length) { |
| 51 | + return { skipped: `No subdomains found for ${domain} in Certificate Transparency logs` }; |
| 52 | + } |
| 53 | + return { |
| 54 | + domain, |
| 55 | + count: all.length, |
| 56 | + truncated: all.length > MAX_SUBDOMAINS, |
| 57 | + subdomains: all.slice(0, MAX_SUBDOMAINS), |
| 58 | + source: 'crt.sh', |
| 59 | + }; |
| 60 | + } catch (error) { |
| 61 | + return upstreamError(error, 'Subdomain lookup'); |
| 62 | + } |
| 63 | +}; |
| 64 | + |
| 65 | +export const handler = middleware(subdomainsHandler); |
| 66 | +export default handler; |
0 commit comments