@@ -2,35 +2,40 @@ import psl from 'psl';
22import middleware from './_common/middleware.js' ;
33import { httpGet } from './_common/http.js' ;
44import { parseTarget } from './_common/parse-target.js' ;
5- import { upstreamError } from './_common/upstream.js' ;
65
76const MAX_SUBDOMAINS = 500 ;
7+ const SOURCE_TIMEOUT = 8000 ;
88const HOSTNAME_RE = / ^ [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] * [ a - z 0 - 9 ] ) ? ( \. [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] * [ a - z 0 - 9 ] ) ? ) + $ / ;
99
10- // Reduce a hostname to its registrable domain so we search the whole zone
1110const baseDomain = ( host ) => psl . parse ( host ) ?. domain || host ;
12-
13- // Skip raw IPs, since CT logs are indexed by hostname not address
1411const isIpAddress = ( host ) => / ^ \d { 1 , 3 } ( \. \d { 1 , 3 } ) { 3 } $ / . test ( host ) || host . includes ( ':' ) ;
1512
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 ( ) ;
13+ const certSpotter = async ( domain ) => {
14+ const token = process . env . CERTSPOTTER_TOKEN ;
15+ const res = await httpGet ( 'https://api.certspotter.com/v1/issuances' , {
16+ params : { domain, include_subdomains : 'true' , expand : 'dns_names' } ,
17+ headers : { Accept : 'application/json' , ...( token && { Authorization : `Bearer ${ token } ` } ) } ,
18+ timeout : SOURCE_TIMEOUT ,
19+ } ) ;
20+ if ( ! Array . isArray ( res . data ) ) throw new Error ( 'certSpotter returned an unexpected response' ) ;
21+ return res . data . flatMap ( ( row ) => ( Array . isArray ( row ?. dns_names ) ? row . dns_names : [ ] ) ) ;
3222} ;
3323
24+ const crtSh = async ( domain ) => {
25+ const res = await httpGet ( 'https://crt.sh/' , {
26+ params : { q : `%.${ domain } ` , output : 'json' } ,
27+ headers : { Accept : 'application/json' } ,
28+ timeout : SOURCE_TIMEOUT ,
29+ } ) ;
30+ if ( ! Array . isArray ( res . data ) ) throw new Error ( 'crt.sh returned an unexpected response' ) ;
31+ return res . data . flatMap ( ( row ) => String ( row ?. name_value ?? '' ) . split ( '\n' ) ) ;
32+ } ;
33+
34+ const SOURCES = [
35+ { name : 'certSpotter' , lookup : certSpotter } ,
36+ { name : 'crt.sh' , lookup : crtSh } ,
37+ ] ;
38+
3439const subdomainsHandler = async ( url ) => {
3540 const { hostname } = parseTarget ( url ) ;
3641 if ( isIpAddress ( hostname ) ) {
@@ -40,31 +45,49 @@ const subdomainsHandler = async (url) => {
4045 if ( ! domain || ! domain . includes ( '.' ) ) {
4146 return { skipped : 'Could not resolve a registrable domain' } ;
4247 }
43- try {
44- const res = await httpGet ( 'https://crt.sh/' , {
45- params : { q : `%.${ domain } ` , output : 'json' } ,
46- headers : { Accept : 'application/json' } ,
47- } ) ;
48- if ( ! Array . isArray ( res . data ) ) {
49- return { error : 'Certificate Transparency lookup returned unexpected data, please retry' } ;
50- }
51- const all = collectSubdomains ( res . data , domain ) ;
52- if ( ! all . length ) {
53- return {
54- skipped : `No subdomains found for ${ domain } in Certificate Transparency logs` ,
55- retryable : true ,
56- } ;
48+
49+ const suffix = `.${ domain } ` ;
50+ const sieve = ( names ) => [
51+ ...new Set (
52+ names
53+ . filter ( ( n ) => typeof n === 'string' )
54+ . map ( ( n ) => n . trim ( ) . toLowerCase ( ) . replace ( / ^ \* \. / , '' ) )
55+ . filter ( ( n ) => n && n !== domain && n . endsWith ( suffix ) && HOSTNAME_RE . test ( n ) ) ,
56+ ) ,
57+ ] . sort ( ) ;
58+
59+ let tried = 0 ;
60+ let succeeded = 0 ;
61+ for ( const source of SOURCES ) {
62+ if ( source . requires && ! process . env [ source . requires ] ) continue ;
63+ tried += 1 ;
64+ try {
65+ const subdomains = sieve ( await source . lookup ( domain ) ) ;
66+ succeeded += 1 ;
67+ if ( subdomains . length ) {
68+ return {
69+ domain,
70+ count : subdomains . length ,
71+ truncated : subdomains . length > MAX_SUBDOMAINS ,
72+ subdomains : subdomains . slice ( 0 , MAX_SUBDOMAINS ) ,
73+ source : source . name ,
74+ } ;
75+ }
76+ } catch {
77+ continue ;
5778 }
58- return {
59- domain,
60- count : all . length ,
61- truncated : all . length > MAX_SUBDOMAINS ,
62- subdomains : all . slice ( 0 , MAX_SUBDOMAINS ) ,
63- source : 'crt.sh' ,
64- } ;
65- } catch ( error ) {
66- return upstreamError ( error , 'Subdomain lookup' ) ;
6779 }
80+
81+ if ( ! tried ) {
82+ return { skipped : 'No subdomain lookup source is configured' } ;
83+ }
84+ if ( ! succeeded ) {
85+ return { error : 'Subdomain lookup failed across all sources, please retry' , retryable : true } ;
86+ }
87+ return {
88+ skipped : `No subdomains found for ${ domain } in Certificate Transparency logs` ,
89+ retryable : true ,
90+ } ;
6891} ;
6992
7093export const handler = middleware ( subdomainsHandler ) ;
0 commit comments