knock 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -10
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +146 -0
- package/dist/index.d.ts +148 -0
- package/dist/index.js +278 -0
- package/dist/psl.d.ts +36 -0
- package/dist/psl.js +125 -0
- package/lists/public_suffix_list.dat +16501 -0
- package/package.json +48 -18
- package/.npmignore +0 -1
- package/knock.js +0 -10
- package/lib.js +0 -49
package/dist/index.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knock — subdomain enumeration via Certificate Transparency logs and
|
|
3
|
+
* wordlist DNS brute forcing, with wildcard DNS detection.
|
|
4
|
+
*/
|
|
5
|
+
import { Resolver } from 'node:dns/promises';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { loadPublicSuffixList, getRegistrableDomain } from './psl.js';
|
|
10
|
+
/** Absolute paths to the wordlists bundled with the package. */
|
|
11
|
+
export const wordlists = {
|
|
12
|
+
subs: fileURLToPath(new URL('../lists/subs.txt', import.meta.url)),
|
|
13
|
+
org: fileURLToPath(new URL('../lists/org.txt', import.meta.url)),
|
|
14
|
+
};
|
|
15
|
+
// DNS answers that simply mean "nothing there", as opposed to a lookup failure.
|
|
16
|
+
const NEGATIVE_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN']);
|
|
17
|
+
const HOSTNAME_PATTERN = /^[a-z0-9_][a-z0-9_.-]*$/;
|
|
18
|
+
// Dotted-quad IPv4 literals have no registrable domain; skip PSL reduction.
|
|
19
|
+
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
20
|
+
const defaults = {
|
|
21
|
+
wordlist: wordlists.subs,
|
|
22
|
+
concurrency: 64,
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
tries: 2,
|
|
25
|
+
family: 4,
|
|
26
|
+
wildcardTests: 3,
|
|
27
|
+
ct: true,
|
|
28
|
+
ctTimeout: 15000,
|
|
29
|
+
verify: true,
|
|
30
|
+
web: false,
|
|
31
|
+
webTimeout: 5000,
|
|
32
|
+
baseDomainOnly: true,
|
|
33
|
+
icannOnly: false,
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Reduce user input like "HTTPS://www.Example.com/path" to a bare hostname.
|
|
37
|
+
* Throws TypeError when no usable hostname can be extracted.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeDomain(input) {
|
|
40
|
+
const raw = String(input ?? '').trim();
|
|
41
|
+
if (raw === '')
|
|
42
|
+
throw new TypeError('domain is required');
|
|
43
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
44
|
+
let hostname;
|
|
45
|
+
try {
|
|
46
|
+
hostname = new URL(url).hostname;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new TypeError(`invalid domain: ${raw}`);
|
|
50
|
+
}
|
|
51
|
+
hostname = hostname.toLowerCase().replace(/\.+$/, '');
|
|
52
|
+
if (hostname === '' || hostname.startsWith('[')) {
|
|
53
|
+
throw new TypeError(`invalid domain: ${raw}`);
|
|
54
|
+
}
|
|
55
|
+
return hostname;
|
|
56
|
+
}
|
|
57
|
+
/** Parse wordlist text into unique, lowercased labels. Blank lines and #comments are skipped. */
|
|
58
|
+
export function parseWordlist(text) {
|
|
59
|
+
const words = new Set();
|
|
60
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
61
|
+
const word = line.trim().toLowerCase();
|
|
62
|
+
if (word === '' || word.startsWith('#'))
|
|
63
|
+
continue;
|
|
64
|
+
words.add(word);
|
|
65
|
+
}
|
|
66
|
+
return [...words];
|
|
67
|
+
}
|
|
68
|
+
/** Read and parse a wordlist file. */
|
|
69
|
+
export async function loadWordlist(path) {
|
|
70
|
+
return parseWordlist(await readFile(path, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Query Certificate Transparency logs (via crt.sh) for names certified under
|
|
74
|
+
* `domain`. Purely passive: one HTTPS request, no DNS traffic to the target.
|
|
75
|
+
* Wildcard entries like "*.dev.example.com" are reported as "dev.example.com".
|
|
76
|
+
* Resolves to a sorted array of hostnames, the apex excluded.
|
|
77
|
+
*/
|
|
78
|
+
export async function certSubdomains(domain, options = {}) {
|
|
79
|
+
const target = normalizeDomain(domain);
|
|
80
|
+
const { timeout = defaults.ctTimeout, signal, fetch: fetchImpl = globalThis.fetch } = options;
|
|
81
|
+
const url = `https://crt.sh/?q=${encodeURIComponent(`%.${target}`)}&output=json`;
|
|
82
|
+
const signals = [AbortSignal.timeout(timeout)];
|
|
83
|
+
if (signal)
|
|
84
|
+
signals.push(signal);
|
|
85
|
+
const response = await fetchImpl(url, {
|
|
86
|
+
signal: AbortSignal.any(signals),
|
|
87
|
+
headers: { accept: 'application/json' },
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok)
|
|
90
|
+
throw new Error(`crt.sh responded with HTTP ${response.status}`);
|
|
91
|
+
const entries = await response.json();
|
|
92
|
+
const names = new Set();
|
|
93
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
94
|
+
const record = entry;
|
|
95
|
+
const candidates = [record?.common_name, ...String(record?.name_value ?? '').split('\n')];
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (!candidate)
|
|
98
|
+
continue;
|
|
99
|
+
let name = String(candidate).trim().toLowerCase().replace(/\.+$/, '');
|
|
100
|
+
if (name.startsWith('*.'))
|
|
101
|
+
name = name.slice(2);
|
|
102
|
+
if (name === target || !name.endsWith(`.${target}`))
|
|
103
|
+
continue;
|
|
104
|
+
if (!HOSTNAME_PATTERN.test(name))
|
|
105
|
+
continue;
|
|
106
|
+
names.add(name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...names].sort();
|
|
110
|
+
}
|
|
111
|
+
function makeResolver({ timeout, tries, servers, }) {
|
|
112
|
+
const resolver = new Resolver({ timeout, tries });
|
|
113
|
+
if (servers && servers.length > 0)
|
|
114
|
+
resolver.setServers(servers);
|
|
115
|
+
return resolver;
|
|
116
|
+
}
|
|
117
|
+
async function lookupName(resolver, name, family) {
|
|
118
|
+
const addresses = [];
|
|
119
|
+
let error;
|
|
120
|
+
const families = family === 'any' ? [4, 6] : [family];
|
|
121
|
+
for (const fam of families) {
|
|
122
|
+
try {
|
|
123
|
+
const found = fam === 6 ? await resolver.resolve6(name) : await resolver.resolve4(name);
|
|
124
|
+
addresses.push(...found);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const code = err?.code;
|
|
128
|
+
if (!NEGATIVE_CODES.has(code ?? ''))
|
|
129
|
+
error = err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { addresses, error };
|
|
133
|
+
}
|
|
134
|
+
// Resolve a few random labels that cannot exist; any answers reveal a wildcard record.
|
|
135
|
+
async function detectWildcard(resolver, domain, { tests, family }) {
|
|
136
|
+
const addresses = new Set();
|
|
137
|
+
for (let i = 0; i < tests; i += 1) {
|
|
138
|
+
const name = `knock-${randomBytes(8).toString('hex')}.${domain}`;
|
|
139
|
+
const { addresses: found } = await lookupName(resolver, name, family);
|
|
140
|
+
for (const address of found)
|
|
141
|
+
addresses.add(address);
|
|
142
|
+
}
|
|
143
|
+
return addresses;
|
|
144
|
+
}
|
|
145
|
+
async function probeWeb(name, timeout) {
|
|
146
|
+
const statuses = { https: null, http: null };
|
|
147
|
+
await Promise.all(['https', 'http'].map(async (protocol) => {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(`${protocol}://${name}/`, {
|
|
150
|
+
method: 'HEAD',
|
|
151
|
+
redirect: 'manual',
|
|
152
|
+
signal: AbortSignal.timeout(timeout),
|
|
153
|
+
});
|
|
154
|
+
statuses[protocol] = response.status;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
statuses[protocol] = null;
|
|
158
|
+
}
|
|
159
|
+
}));
|
|
160
|
+
return statuses;
|
|
161
|
+
}
|
|
162
|
+
async function pool(items, worker, concurrency, signal) {
|
|
163
|
+
let index = 0;
|
|
164
|
+
const size = Math.max(1, Math.min(concurrency, items.length));
|
|
165
|
+
const runners = Array.from({ length: size }, async () => {
|
|
166
|
+
while (index < items.length && !signal?.aborted) {
|
|
167
|
+
const item = items[index];
|
|
168
|
+
index += 1;
|
|
169
|
+
await worker(item);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
await Promise.all(runners);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Enumerate subdomains of `domain`.
|
|
176
|
+
*
|
|
177
|
+
* Two sources feed the candidate set: Certificate Transparency logs
|
|
178
|
+
* (passive, `ct` option) and a wordlist brute force over DNS. Candidates
|
|
179
|
+
* are resolved concurrently; hosts that only echo a wildcard DNS answer are
|
|
180
|
+
* suppressed unless Certificate Transparency vouches for them.
|
|
181
|
+
*/
|
|
182
|
+
export async function knock(domain, options = {}) {
|
|
183
|
+
const opts = { ...defaults, ...options };
|
|
184
|
+
let target = normalizeDomain(domain);
|
|
185
|
+
if (opts.baseDomainOnly && !IPV4_PATTERN.test(target)) {
|
|
186
|
+
const rules = opts.psl ?? (await loadPublicSuffixList());
|
|
187
|
+
const base = getRegistrableDomain(target, rules, { icannOnly: opts.icannOnly });
|
|
188
|
+
if (base)
|
|
189
|
+
target = base;
|
|
190
|
+
}
|
|
191
|
+
const words = opts.words ?? (await loadWordlist(opts.wordlist));
|
|
192
|
+
const resolver = opts.resolver ?? makeResolver(opts);
|
|
193
|
+
const { signal } = opts;
|
|
194
|
+
signal?.throwIfAborted();
|
|
195
|
+
const started = Date.now();
|
|
196
|
+
const ct = { enabled: Boolean(opts.ct), names: 0, unresolved: [], error: null };
|
|
197
|
+
const candidates = new Map();
|
|
198
|
+
for (const word of words) {
|
|
199
|
+
candidates.set(`${word}.${target}`, new Set(['wordlist']));
|
|
200
|
+
}
|
|
201
|
+
if (opts.ct) {
|
|
202
|
+
try {
|
|
203
|
+
const names = await certSubdomains(target, {
|
|
204
|
+
timeout: opts.ctTimeout,
|
|
205
|
+
signal,
|
|
206
|
+
fetch: opts.fetch,
|
|
207
|
+
});
|
|
208
|
+
ct.names = names.length;
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const sources = candidates.get(name);
|
|
211
|
+
if (sources)
|
|
212
|
+
sources.add('ct');
|
|
213
|
+
else
|
|
214
|
+
candidates.set(name, new Set(['ct']));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (signal?.aborted)
|
|
219
|
+
throw err;
|
|
220
|
+
ct.error = err instanceof Error ? err.message : String(err);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const wildcardAddresses = words.length > 0 && opts.wildcardTests > 0
|
|
224
|
+
? await detectWildcard(resolver, target, { tests: opts.wildcardTests, family: opts.family })
|
|
225
|
+
: new Set();
|
|
226
|
+
const results = [];
|
|
227
|
+
let queried = 0;
|
|
228
|
+
let errors = 0;
|
|
229
|
+
await pool([...candidates.entries()], async ([name, sources]) => {
|
|
230
|
+
const fromCt = sources.has('ct');
|
|
231
|
+
const emit = async (addresses) => {
|
|
232
|
+
const result = { name, addresses, sources: [...sources] };
|
|
233
|
+
if (opts.web)
|
|
234
|
+
result.web = await probeWeb(name, opts.webTimeout);
|
|
235
|
+
results.push(result);
|
|
236
|
+
opts.onResult?.(result);
|
|
237
|
+
};
|
|
238
|
+
if (fromCt && !sources.has('wordlist') && !opts.verify) {
|
|
239
|
+
await emit([]);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const { addresses, error } = await lookupName(resolver, name, opts.family);
|
|
243
|
+
queried += 1;
|
|
244
|
+
if (addresses.length === 0) {
|
|
245
|
+
if (error)
|
|
246
|
+
errors += 1;
|
|
247
|
+
if (fromCt)
|
|
248
|
+
ct.unresolved.push(name);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// A wordlist hit that only mirrors the wildcard answer is not a real
|
|
252
|
+
// discovery; a certificate in the CT logs is evidence on its own.
|
|
253
|
+
if (!fromCt &&
|
|
254
|
+
wildcardAddresses.size > 0 &&
|
|
255
|
+
addresses.every((address) => wildcardAddresses.has(address))) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await emit(addresses);
|
|
259
|
+
}, opts.concurrency, signal);
|
|
260
|
+
signal?.throwIfAborted();
|
|
261
|
+
ct.unresolved.sort();
|
|
262
|
+
return {
|
|
263
|
+
domain: target,
|
|
264
|
+
wildcard: { detected: wildcardAddresses.size > 0, addresses: [...wildcardAddresses] },
|
|
265
|
+
ct,
|
|
266
|
+
results,
|
|
267
|
+
stats: {
|
|
268
|
+
words: words.length,
|
|
269
|
+
candidates: candidates.size,
|
|
270
|
+
queried,
|
|
271
|
+
found: results.length,
|
|
272
|
+
errors,
|
|
273
|
+
durationMs: Date.now() - started,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export { publicSuffixListPath, parsePublicSuffixList, loadPublicSuffixList, getPublicSuffix, getRegistrableDomain, } from './psl.js';
|
|
278
|
+
export default knock;
|
package/dist/psl.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Absolute path to the vendored Public Suffix List. */
|
|
2
|
+
export declare const publicSuffixListPath: string;
|
|
3
|
+
/** A single Public Suffix List rule. */
|
|
4
|
+
export interface PublicSuffixRule {
|
|
5
|
+
/** The rule began with `!`, marking an exception to a wildcard rule. */
|
|
6
|
+
exception: boolean;
|
|
7
|
+
/** From the ICANN section rather than the PRIVATE section of the list. */
|
|
8
|
+
icann: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Parsed rule set: punycode rule string (without any leading `!`) → rule. */
|
|
11
|
+
export type PublicSuffixRules = Map<string, PublicSuffixRule>;
|
|
12
|
+
/** Options shared by the Public Suffix List lookups. */
|
|
13
|
+
export interface PublicSuffixOptions {
|
|
14
|
+
/** Ignore the PRIVATE section (github.io, herokuapp.com, …). Default false. */
|
|
15
|
+
icannOnly?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse Public Suffix List text into a rule map. Each line is read only up to
|
|
19
|
+
* the first whitespace; `//` lines and blanks are skipped. The ICANN/PRIVATE
|
|
20
|
+
* section is tracked from the `===BEGIN …===` markers.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parsePublicSuffixList(text: string): PublicSuffixRules;
|
|
23
|
+
/** Read and parse a Public Suffix List file, memoized per path. */
|
|
24
|
+
export declare function loadPublicSuffixList(path?: string): Promise<PublicSuffixRules>;
|
|
25
|
+
/**
|
|
26
|
+
* The public suffix of `hostname` (e.g. "co.uk", "github.io"), or null when the
|
|
27
|
+
* hostname has fewer labels than the matching rule. `hostname` should already
|
|
28
|
+
* be a bare, lowercased, punycode host as produced by `normalizeDomain`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getPublicSuffix(hostname: string, rules: PublicSuffixRules, options?: PublicSuffixOptions): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* The registrable domain of `hostname` — its public suffix plus one label
|
|
33
|
+
* (e.g. "www.example.co.uk" → "example.co.uk"). Returns null when `hostname` is
|
|
34
|
+
* itself a public suffix (or shorter), i.e. has no registrable domain.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getRegistrableDomain(hostname: string, rules: PublicSuffixRules, options?: PublicSuffixOptions): string | null;
|
package/dist/psl.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public Suffix List support — reduce a hostname to its registrable domain.
|
|
3
|
+
*
|
|
4
|
+
* Where a name may be registered (`co.uk`, `github.io`, `s3.amazonaws.com`)
|
|
5
|
+
* cannot be inferred from string rules alone, so knock uses the Public Suffix
|
|
6
|
+
* List. The data is vendored in `lists/public_suffix_list.dat`; refresh it with
|
|
7
|
+
* `npm run update-psl`.
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/publicsuffix/list/wiki/Format for the rule format and
|
|
10
|
+
* the matching algorithm implemented in {@link publicSuffixLength}.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { domainToASCII, fileURLToPath } from 'node:url';
|
|
14
|
+
/** Absolute path to the vendored Public Suffix List. */
|
|
15
|
+
export const publicSuffixListPath = fileURLToPath(new URL('../lists/public_suffix_list.dat', import.meta.url));
|
|
16
|
+
// Punycode a rule so it matches the ASCII hostnames the WHATWG URL parser
|
|
17
|
+
// produces (the list is UTF-8; hostnames come through as xn--…). A leftmost
|
|
18
|
+
// `*` wildcard is preserved.
|
|
19
|
+
function toAscii(name) {
|
|
20
|
+
if (name === '*')
|
|
21
|
+
return '*';
|
|
22
|
+
if (name.startsWith('*.')) {
|
|
23
|
+
const rest = domainToASCII(name.slice(2));
|
|
24
|
+
return rest ? `*.${rest}` : name.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
return domainToASCII(name) || name.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse Public Suffix List text into a rule map. Each line is read only up to
|
|
30
|
+
* the first whitespace; `//` lines and blanks are skipped. The ICANN/PRIVATE
|
|
31
|
+
* section is tracked from the `===BEGIN …===` markers.
|
|
32
|
+
*/
|
|
33
|
+
export function parsePublicSuffixList(text) {
|
|
34
|
+
const rules = new Map();
|
|
35
|
+
let icann = true;
|
|
36
|
+
for (const rawLine of String(text).split(/\r?\n/)) {
|
|
37
|
+
if (rawLine.includes('===BEGIN ICANN DOMAINS===')) {
|
|
38
|
+
icann = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (rawLine.includes('===BEGIN PRIVATE DOMAINS===')) {
|
|
42
|
+
icann = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const token = rawLine.trim().split(/\s+/)[0] ?? '';
|
|
46
|
+
if (token === '' || token.startsWith('//'))
|
|
47
|
+
continue;
|
|
48
|
+
const exception = token.startsWith('!');
|
|
49
|
+
rules.set(toAscii(exception ? token.slice(1) : token), { exception, icann });
|
|
50
|
+
}
|
|
51
|
+
return rules;
|
|
52
|
+
}
|
|
53
|
+
const cache = new Map();
|
|
54
|
+
/** Read and parse a Public Suffix List file, memoized per path. */
|
|
55
|
+
export function loadPublicSuffixList(path = publicSuffixListPath) {
|
|
56
|
+
let pending = cache.get(path);
|
|
57
|
+
if (!pending) {
|
|
58
|
+
pending = readFile(path, 'utf8')
|
|
59
|
+
.then(parsePublicSuffixList)
|
|
60
|
+
.catch((error) => {
|
|
61
|
+
cache.delete(path);
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
cache.set(path, pending);
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
// Number of labels in the public suffix of `labels`, per the PSL algorithm:
|
|
69
|
+
// an exception rule prevails over any wildcard/normal rule; otherwise the rule
|
|
70
|
+
// with the most labels prevails; with no match the default rule is `*` (one
|
|
71
|
+
// label). An exception rule's suffix is one label shorter than the rule.
|
|
72
|
+
function publicSuffixLength(labels, rules, icannOnly) {
|
|
73
|
+
let best = null;
|
|
74
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
75
|
+
const length = labels.length - i;
|
|
76
|
+
const exact = labels.slice(i).join('.');
|
|
77
|
+
const wildcard = ['*', ...labels.slice(i + 1)].join('.');
|
|
78
|
+
for (const key of exact === wildcard ? [exact] : [exact, wildcard]) {
|
|
79
|
+
const rule = rules.get(key);
|
|
80
|
+
if (!rule || (icannOnly && !rule.icann))
|
|
81
|
+
continue;
|
|
82
|
+
if (!best ||
|
|
83
|
+
(rule.exception && !best.exception) ||
|
|
84
|
+
(rule.exception === best.exception && length > best.length)) {
|
|
85
|
+
best = { exception: rule.exception, length };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!best)
|
|
90
|
+
return 1;
|
|
91
|
+
return best.exception ? best.length - 1 : best.length;
|
|
92
|
+
}
|
|
93
|
+
function cleanHost(hostname) {
|
|
94
|
+
return String(hostname).toLowerCase().replace(/\.+$/, '');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The public suffix of `hostname` (e.g. "co.uk", "github.io"), or null when the
|
|
98
|
+
* hostname has fewer labels than the matching rule. `hostname` should already
|
|
99
|
+
* be a bare, lowercased, punycode host as produced by `normalizeDomain`.
|
|
100
|
+
*/
|
|
101
|
+
export function getPublicSuffix(hostname, rules, options = {}) {
|
|
102
|
+
const host = cleanHost(hostname);
|
|
103
|
+
if (host === '')
|
|
104
|
+
return null;
|
|
105
|
+
const labels = host.split('.');
|
|
106
|
+
const length = publicSuffixLength(labels, rules, options.icannOnly ?? false);
|
|
107
|
+
if (length > labels.length)
|
|
108
|
+
return null;
|
|
109
|
+
return labels.slice(labels.length - length).join('.');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The registrable domain of `hostname` — its public suffix plus one label
|
|
113
|
+
* (e.g. "www.example.co.uk" → "example.co.uk"). Returns null when `hostname` is
|
|
114
|
+
* itself a public suffix (or shorter), i.e. has no registrable domain.
|
|
115
|
+
*/
|
|
116
|
+
export function getRegistrableDomain(hostname, rules, options = {}) {
|
|
117
|
+
const host = cleanHost(hostname);
|
|
118
|
+
if (host === '')
|
|
119
|
+
return null;
|
|
120
|
+
const labels = host.split('.');
|
|
121
|
+
const length = publicSuffixLength(labels, rules, options.icannOnly ?? false);
|
|
122
|
+
if (labels.length <= length)
|
|
123
|
+
return null;
|
|
124
|
+
return labels.slice(labels.length - length - 1).join('.');
|
|
125
|
+
}
|