Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions api/subdomains.js
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 48 additions & 0 deletions src/client/components/Results/Subdomains.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card heading={props.title} actionButtons={props.actionButtons} styles={cardStyles}>
<Row lbl="Base Domain" val={domain} />
<Row lbl="Subdomains Found" val={count} />
{truncated && <Row lbl="Showing" val={`First ${subdomains.length}`} />}
<ul>
{subdomains.map((sub: string) => (
<li key={sub}>
<a href={`https://${sub}`} target="_blank" rel="noreferrer">
{sub}
</a>
</li>
))}
</ul>
{source && <small>Source: Certificate Transparency logs via {source}</small>}
</Card>
);
};

export default SubdomainsCard;
7 changes: 7 additions & 0 deletions src/client/jobs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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],
Expand Down
19 changes: 19 additions & 0 deletions src/client/utils/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down