Skip to content

Commit ccbc416

Browse files
authored
Merge pull request #304 from Lissy93/feat/new-checks
Subdomain lookup
2 parents 57d5454 + 7e3ea5b commit ccbc416

4 files changed

Lines changed: 140 additions & 0 deletions

File tree

api/subdomains.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Card } from 'client/components/Form/Card';
2+
import Row from 'client/components/Form/Row';
3+
import colors from 'client/styles/colors';
4+
5+
const cardStyles = `
6+
ul {
7+
list-style: none;
8+
padding: 0;
9+
margin: 0.5rem 0 0 0;
10+
li {
11+
padding: 0.25rem;
12+
border-bottom: 1px solid ${colors.primaryTransparent};
13+
&:last-child { border-bottom: none }
14+
}
15+
a {
16+
color: ${colors.textColor};
17+
&:hover { color: ${colors.primary} }
18+
}
19+
}
20+
small {
21+
display: block;
22+
margin-top: 0.75rem;
23+
opacity: 0.5;
24+
}
25+
`;
26+
27+
const SubdomainsCard = (props: { data: any; title: string; actionButtons: any }): JSX.Element => {
28+
const { domain, count, truncated, subdomains = [], source } = props.data;
29+
return (
30+
<Card heading={props.title} actionButtons={props.actionButtons} styles={cardStyles}>
31+
<Row lbl="Base Domain" val={domain} />
32+
<Row lbl="Subdomains Found" val={count} />
33+
{truncated && <Row lbl="Showing" val={`First ${subdomains.length}`} />}
34+
<ul>
35+
{subdomains.map((sub: string) => (
36+
<li key={sub}>
37+
<a href={`https://${sub}`} target="_blank" rel="noreferrer">
38+
{sub}
39+
</a>
40+
</li>
41+
))}
42+
</ul>
43+
{source && <small>Source: Certificate Transparency logs via {source}</small>}
44+
</Card>
45+
);
46+
};
47+
48+
export default SubdomainsCard;

src/client/jobs/registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import ThreatsCard from 'client/components/Results/Threats';
3737
import TlsConnectionCard from 'client/components/Results/TlsConnection';
3838
import TlsSecurityAuditCard from 'client/components/Results/TlsSecurityAudit';
3939
import TlsClientCompatCard from 'client/components/Results/TlsClientCompat';
40+
import SubdomainsCard from 'client/components/Results/Subdomains';
4041

4142
import type { JobSpec, JobContext, JobsState } from './types';
4243

@@ -213,6 +214,12 @@ export const jobs: JobSpec[] = [
213214
],
214215
fetcher: fetchAndPoll('tls-labs?url=${url}'),
215216
},
217+
{
218+
id: 'subdomains',
219+
expectedAddressTypes: [...URL_ONLY],
220+
cards: [card('subdomains', 'Subdomains', ['server', 'meta'], SubdomainsCard)],
221+
fetcher: fetchAndRetry('subdomains?url=${url}'),
222+
},
216223
{
217224
id: 'trace-route',
218225
expectedAddressTypes: [...URL_ONLY],

src/client/utils/docs.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,25 @@ const docs: Doc[] = [
642642
resources: [],
643643
screenshot: 'https://pixelflare.cc/alicia/web-check/wc-screenshot',
644644
},
645+
{
646+
id: 'subdomains',
647+
title: 'Subdomains',
648+
description:
649+
'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.',
650+
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.",
651+
resources: [
652+
{ title: 'crt.sh', link: 'https://crt.sh/' },
653+
{
654+
title: 'Certificate Transparency',
655+
link: 'https://en.wikipedia.org/wiki/Certificate_Transparency',
656+
},
657+
{ title: 'RFC-6962 (CT)', link: 'https://datatracker.ietf.org/doc/html/rfc6962' },
658+
{
659+
title: 'OWASP - Subdomain Enumeration',
660+
link: 'https://owasp.org/www-community/attacks/Subdomain_Takeover',
661+
},
662+
],
663+
},
645664
];
646665

647666
export const featureIntro = [

0 commit comments

Comments
 (0)