diff --git a/api/subdomains.js b/api/subdomains.js new file mode 100644 index 00000000..5b03c39a --- /dev/null +++ b/api/subdomains.js @@ -0,0 +1,66 @@ +import psl from 'psl'; +import middleware from './_common/middleware.js'; +import { httpGet } from './_common/http.js'; +import { parseTarget } from './_common/parse-target.js'; +import { upstreamError } from './_common/upstream.js'; + +const MAX_SUBDOMAINS = 500; +const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +// Reduce a hostname to its registrable domain so we search the whole zone +const baseDomain = (host) => psl.parse(host)?.domain || host; + +// Skip raw IPs, since CT logs are indexed by hostname not address +const isIpAddress = (host) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':'); + +// Flatten crt.sh rows into a clean, deduped list of valid subdomains under the base +const collectSubdomains = (rows, base) => { + const suffix = `.${base}`; + const out = new Set(); + for (const row of rows) { + const raw = row?.name_value; + if (typeof raw !== 'string') continue; + for (const part of raw.split('\n')) { + const name = part.trim().toLowerCase().replace(/^\*\./, ''); + if (!name || name === base) continue; + if (!name.endsWith(suffix)) continue; + if (!HOSTNAME_RE.test(name)) continue; + out.add(name); + } + } + return [...out].sort(); +}; + +const subdomainsHandler = async (url) => { + const { hostname } = parseTarget(url); + if (isIpAddress(hostname)) { + return { skipped: 'Subdomain enumeration only applies to domain names' }; + } + const domain = baseDomain(hostname); + if (!domain || !domain.includes('.')) { + return { skipped: 'Could not resolve a registrable domain' }; + } + try { + const res = await httpGet('https://crt.sh/', { + params: { q: `%.${domain}`, output: 'json' }, + headers: { Accept: 'application/json' }, + }); + const rows = Array.isArray(res.data) ? res.data : []; + const all = collectSubdomains(rows, domain); + if (!all.length) { + return { skipped: `No subdomains found for ${domain} in Certificate Transparency logs` }; + } + return { + domain, + count: all.length, + truncated: all.length > MAX_SUBDOMAINS, + subdomains: all.slice(0, MAX_SUBDOMAINS), + source: 'crt.sh', + }; + } catch (error) { + return upstreamError(error, 'Subdomain lookup'); + } +}; + +export const handler = middleware(subdomainsHandler); +export default handler; diff --git a/src/client/components/Results/Subdomains.tsx b/src/client/components/Results/Subdomains.tsx new file mode 100644 index 00000000..12874386 --- /dev/null +++ b/src/client/components/Results/Subdomains.tsx @@ -0,0 +1,48 @@ +import { Card } from 'client/components/Form/Card'; +import Row from 'client/components/Form/Row'; +import colors from 'client/styles/colors'; + +const cardStyles = ` + ul { + list-style: none; + padding: 0; + margin: 0.5rem 0 0 0; + li { + padding: 0.25rem; + border-bottom: 1px solid ${colors.primaryTransparent}; + &:last-child { border-bottom: none } + } + a { + color: ${colors.textColor}; + &:hover { color: ${colors.primary} } + } + } + small { + display: block; + margin-top: 0.75rem; + opacity: 0.5; + } +`; + +const SubdomainsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => { + const { domain, count, truncated, subdomains = [], source } = props.data; + return ( + + + + {truncated && } +
    + {subdomains.map((sub: string) => ( +
  • + + {sub} + +
  • + ))} +
+ {source && Source: Certificate Transparency logs via {source}} +
+ ); +}; + +export default SubdomainsCard; diff --git a/src/client/jobs/registry.ts b/src/client/jobs/registry.ts index 63ff2e85..b0501528 100644 --- a/src/client/jobs/registry.ts +++ b/src/client/jobs/registry.ts @@ -37,6 +37,7 @@ import ThreatsCard from 'client/components/Results/Threats'; import TlsConnectionCard from 'client/components/Results/TlsConnection'; import TlsSecurityAuditCard from 'client/components/Results/TlsSecurityAudit'; import TlsClientCompatCard from 'client/components/Results/TlsClientCompat'; +import SubdomainsCard from 'client/components/Results/Subdomains'; import type { JobSpec, JobContext, JobsState } from './types'; @@ -213,6 +214,12 @@ export const jobs: JobSpec[] = [ ], fetcher: fetchAndPoll('tls-labs?url=${url}'), }, + { + id: 'subdomains', + expectedAddressTypes: [...URL_ONLY], + cards: [card('subdomains', 'Subdomains', ['server', 'meta'], SubdomainsCard)], + fetcher: fetchAndRetry('subdomains?url=${url}'), + }, { id: 'trace-route', expectedAddressTypes: [...URL_ONLY], diff --git a/src/client/utils/docs.ts b/src/client/utils/docs.ts index bcb459c5..b5ffe08a 100644 --- a/src/client/utils/docs.ts +++ b/src/client/utils/docs.ts @@ -642,6 +642,25 @@ const docs: Doc[] = [ resources: [], screenshot: 'https://pixelflare.cc/alicia/web-check/wc-screenshot', }, + { + id: 'subdomains', + title: 'Subdomains', + description: + 'Discovers subdomains belonging to a target domain by querying public Certificate Transparency logs (via crt.sh). Every TLS certificate issued for a hostname is logged publicly, so the CT logs effectively reveal any subdomain the operator has ever requested a cert for. The check resolves the registrable domain (eTLD+1), then collects, deduplicates and filters the SAN values from every cert ever issued under that zone.', + use: "Subdomains are a classic part of an attack surface map. They often expose dev, staging, admin or legacy services that the main site's owners forgot about, and which may not be hardened to the same standard as production. From a defensive standpoint, this lets you audit what is publicly discoverable about your own infrastructure. For OSINT investigators, the list of subdomains can hint at a target's internal services, vendors, regions, and historical projects.", + resources: [ + { title: 'crt.sh', link: 'https://crt.sh/' }, + { + title: 'Certificate Transparency', + link: 'https://en.wikipedia.org/wiki/Certificate_Transparency', + }, + { title: 'RFC-6962 (CT)', link: 'https://datatracker.ietf.org/doc/html/rfc6962' }, + { + title: 'OWASP - Subdomain Enumeration', + link: 'https://owasp.org/www-community/attacks/Subdomain_Takeover', + }, + ], + }, ]; export const featureIntro = [