@slicedearth/whoisleuth-cli 1.32.2 → 1.36.1
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/README.md +43 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +180 -8
- package/cli/artifact-verify.mjs +32 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/bulk.mjs +1 -2
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +70 -7
- package/cli/doctor.mjs +78 -43
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +13 -7
- package/cli/investigation-plan.mjs +112 -0
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/manual.mjs +2 -1
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +214 -11
- package/docs/cli.md +200 -45
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/lib/availability.mjs +30 -10
- package/lib/bulk-limits.mjs +13 -0
- package/lib/csv.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-control-manifest.mjs +339 -0
- package/lib/domain-posture.mjs +2 -1
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +3 -3
- package/lib/evidence-report.mjs +3 -0
- package/lib/favicon.mjs +3 -8
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +54 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-search-workbench.mjs +157 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +11 -10
- package/lib/response-policy.mjs +81 -6
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threatfox-intelligence.mjs +10 -5
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +10 -5
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +11 -4
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +7 -5
- package/third-party-notices.txt +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { domainToASCII } from 'node:url';
|
|
4
|
+
const TLSA_EVIDENCE_SCHEMA = 'whoisleuth.tlsa-evidence';
|
|
5
|
+
const TLSA_EVIDENCE_VERSION = 1;
|
|
6
|
+
const MAX_TLSA_RECORDS = 50;
|
|
7
|
+
const MAX_CERTIFICATE_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_AUTHORITY_MATERIALS = 10;
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
function integer(value, minimum, maximum) {
|
|
15
|
+
return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
|
|
16
|
+
? Number(value)
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
function hex(value) {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return null;
|
|
22
|
+
const normalized = value.replace(/\s+/gu, '').toLowerCase();
|
|
23
|
+
return normalized && normalized.length % 2 === 0 && /^[0-9a-f]+$/u.test(normalized) ? normalized : null;
|
|
24
|
+
}
|
|
25
|
+
function normalizeTlsaRecord(value) {
|
|
26
|
+
const source = record(value);
|
|
27
|
+
if (!source)
|
|
28
|
+
return null;
|
|
29
|
+
const usage = integer(source.usage, 0, 3);
|
|
30
|
+
const selector = integer(source.selector, 0, 1);
|
|
31
|
+
const matchingType = integer(source.matchingType, 0, 2);
|
|
32
|
+
const associationData = hex(source.associationData);
|
|
33
|
+
if (usage === null || selector === null || matchingType === null || !associationData)
|
|
34
|
+
return null;
|
|
35
|
+
if (matchingType === 1 && associationData.length !== 64)
|
|
36
|
+
return null;
|
|
37
|
+
if (matchingType === 2 && associationData.length !== 128)
|
|
38
|
+
return null;
|
|
39
|
+
if (matchingType === 0 && associationData.length > MAX_CERTIFICATE_BYTES * 2)
|
|
40
|
+
return null;
|
|
41
|
+
return Object.freeze({ usage, selector, matchingType, associationData });
|
|
42
|
+
}
|
|
43
|
+
function decodeBoundedBase64(value) {
|
|
44
|
+
if (typeof value !== 'string' || value.length > MAX_CERTIFICATE_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value))
|
|
45
|
+
return null;
|
|
46
|
+
const decoded = Buffer.from(value, 'base64');
|
|
47
|
+
return decoded.length > 0 && decoded.length <= MAX_CERTIFICATE_BYTES && decoded.toString('base64') === value ? decoded : null;
|
|
48
|
+
}
|
|
49
|
+
function normalizeTlsaServiceName(value) {
|
|
50
|
+
if (typeof value !== 'string' || value.length > 270)
|
|
51
|
+
return null;
|
|
52
|
+
const match = /^_(\d{1,5})\._(tcp|udp)\.(.+)$/iu.exec(value.trim().replace(/\.$/u, ''));
|
|
53
|
+
if (!match)
|
|
54
|
+
return null;
|
|
55
|
+
const port = Number(match[1]);
|
|
56
|
+
const transport = match[2]?.toLowerCase();
|
|
57
|
+
const hostname = domainToASCII(String(match[3] ?? '').toLowerCase());
|
|
58
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535 || (transport !== 'tcp' && transport !== 'udp'))
|
|
59
|
+
return null;
|
|
60
|
+
if (!hostname || hostname.length > 253 || hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
|
|
61
|
+
return null;
|
|
62
|
+
return Object.freeze({ ownerName: `_${port}._${transport}.${hostname}`, port, transport, hostname });
|
|
63
|
+
}
|
|
64
|
+
function normalizeCertificateMaterial(value) {
|
|
65
|
+
const source = record(value);
|
|
66
|
+
if (!source)
|
|
67
|
+
return null;
|
|
68
|
+
const certificateDer = decodeBoundedBase64(source.certificateDerBase64);
|
|
69
|
+
const spkiDer = decodeBoundedBase64(source.spkiDerBase64);
|
|
70
|
+
return certificateDer || spkiDer ? Object.freeze({ certificateDer, spkiDer }) : null;
|
|
71
|
+
}
|
|
72
|
+
function selectedValues(recordValue, materials) {
|
|
73
|
+
return materials.flatMap((material) => {
|
|
74
|
+
const selected = recordValue.selector === 0 ? material.certificateDer : material.spkiDer;
|
|
75
|
+
if (!selected)
|
|
76
|
+
return [];
|
|
77
|
+
if (recordValue.matchingType === 0)
|
|
78
|
+
return [selected.toString('hex')];
|
|
79
|
+
if (recordValue.matchingType === 1)
|
|
80
|
+
return [createHash('sha256').update(selected).digest('hex')];
|
|
81
|
+
return [createHash('sha512').update(selected).digest('hex')];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function normalizeDnssecState(value) {
|
|
85
|
+
return value === 'validated' || value === 'insecure' || value === 'bogus' ? value : 'unavailable';
|
|
86
|
+
}
|
|
87
|
+
function normalizePkixValidationState(value) {
|
|
88
|
+
return value === 'validated' || value === 'failed' ? value : 'unavailable';
|
|
89
|
+
}
|
|
90
|
+
function analyzeTlsaEvidence(input) {
|
|
91
|
+
const service = normalizeTlsaServiceName(input.serviceName);
|
|
92
|
+
const dnssecState = normalizeDnssecState(input.dnssecState);
|
|
93
|
+
const pkixValidationState = normalizePkixValidationState(input.pkixValidationState);
|
|
94
|
+
const leafMaterial = normalizeCertificateMaterial({
|
|
95
|
+
certificateDerBase64: input.certificateDerBase64,
|
|
96
|
+
spkiDerBase64: input.spkiDerBase64,
|
|
97
|
+
});
|
|
98
|
+
const rawAuthorityMaterials = Array.isArray(input.authorityMaterials) ? input.authorityMaterials : [];
|
|
99
|
+
const normalizedAuthorityMaterials = rawAuthorityMaterials
|
|
100
|
+
.slice(0, MAX_AUTHORITY_MATERIALS)
|
|
101
|
+
.map(normalizeCertificateMaterial);
|
|
102
|
+
const authorityMaterials = normalizedAuthorityMaterials
|
|
103
|
+
.filter((item) => item !== null);
|
|
104
|
+
const authorityMaterialRejectedCount = normalizedAuthorityMaterials.length - authorityMaterials.length;
|
|
105
|
+
const authorityMaterialTruncated = rawAuthorityMaterials.length > MAX_AUTHORITY_MATERIALS;
|
|
106
|
+
const raw = Array.isArray(input.records) ? input.records : null;
|
|
107
|
+
if (!raw || !service) {
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
schema: TLSA_EVIDENCE_SCHEMA,
|
|
110
|
+
version: TLSA_EVIDENCE_VERSION,
|
|
111
|
+
state: 'invalid',
|
|
112
|
+
dnssecState,
|
|
113
|
+
pkixValidationState,
|
|
114
|
+
service,
|
|
115
|
+
authorityMaterialCount: authorityMaterials.length,
|
|
116
|
+
authorityMaterialRejectedCount,
|
|
117
|
+
authorityMaterialTruncated,
|
|
118
|
+
records: Object.freeze([]),
|
|
119
|
+
rejectedCount: 0,
|
|
120
|
+
truncated: false,
|
|
121
|
+
limitations: Object.freeze([
|
|
122
|
+
...(!raw ? ['TLSA evidence must be supplied as a bounded record array.'] : []),
|
|
123
|
+
...(!service ? ['TLSA evidence must be bound to a valid _port._transport.hostname service name.'] : []),
|
|
124
|
+
]),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const truncated = raw.length > MAX_TLSA_RECORDS;
|
|
128
|
+
const normalized = raw.slice(0, MAX_TLSA_RECORDS).map(normalizeTlsaRecord);
|
|
129
|
+
const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, raw.length - MAX_TLSA_RECORDS);
|
|
130
|
+
const records = normalized.filter((item) => item !== null);
|
|
131
|
+
const results = records.map((item) => {
|
|
132
|
+
const materials = item.usage === 0 || item.usage === 2
|
|
133
|
+
? authorityMaterials
|
|
134
|
+
: leafMaterial ? [leafMaterial] : [];
|
|
135
|
+
const selected = selectedValues(item, materials);
|
|
136
|
+
if (!selected.length) {
|
|
137
|
+
const role = item.usage === 0 || item.usage === 2 ? 'authority' : 'leaf';
|
|
138
|
+
return Object.freeze({ ...item, state: 'unavailable', reason: `No bounded ${role} ${item.selector === 0 ? 'certificate' : 'SPKI'} bytes were supplied for comparison.` });
|
|
139
|
+
}
|
|
140
|
+
const matched = selected.includes(item.associationData);
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
...item,
|
|
143
|
+
state: matched ? 'matched' : 'different',
|
|
144
|
+
reason: matched ? 'The supplied certificate material matched this TLSA association.' : 'The supplied certificate material differed from this TLSA association.',
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
const hasMatch = results.some((item) => item.state === 'matched');
|
|
148
|
+
const hasDifferent = results.some((item) => item.state === 'different');
|
|
149
|
+
const hasUnavailable = results.some((item) => item.state === 'unavailable');
|
|
150
|
+
const limitations = [
|
|
151
|
+
'This offline comparison does not connect to the target, retrieve DNS, negotiate SMTP STARTTLS, or validate a DNSSEC chain.',
|
|
152
|
+
'A TLSA match is usable as DANE evidence only when the DNSSEC state was independently validated for the same observation.',
|
|
153
|
+
'PKIX-TA and PKIX-EE usages require an independently validated PKIX path; association matching alone does not establish that prerequisite.',
|
|
154
|
+
];
|
|
155
|
+
if (dnssecState !== 'validated' && hasMatch) {
|
|
156
|
+
limitations.push('Certificate material matched, but DNSSEC was not validated, so the result remains partial.');
|
|
157
|
+
}
|
|
158
|
+
const matchedPkixDependent = results.some((item) => item.state === 'matched' && (item.usage === 0 || item.usage === 1));
|
|
159
|
+
const matchedDaneOnly = results.some((item) => item.state === 'matched' && (item.usage === 2 || item.usage === 3));
|
|
160
|
+
if (matchedPkixDependent && pkixValidationState !== 'validated') {
|
|
161
|
+
limitations.push('A PKIX-dependent association matched, but a validated PKIX path was not supplied, so the result is not complete.');
|
|
162
|
+
}
|
|
163
|
+
if (authorityMaterialRejectedCount > 0 || authorityMaterialTruncated) {
|
|
164
|
+
limitations.push('Authority certificate material was malformed or exceeded the review bound, so an unmatched trust-anchor association remains partial.');
|
|
165
|
+
}
|
|
166
|
+
const trustedMatch = dnssecState === 'validated'
|
|
167
|
+
&& (matchedDaneOnly || matchedPkixDependent && pkixValidationState === 'validated');
|
|
168
|
+
const state = results.length === 0
|
|
169
|
+
? 'unavailable'
|
|
170
|
+
: hasDifferent && !hasMatch && rejectedCount === 0 && !truncated
|
|
171
|
+
&& authorityMaterialRejectedCount === 0 && !authorityMaterialTruncated
|
|
172
|
+
? 'different'
|
|
173
|
+
: hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !matchedDaneOnly)
|
|
174
|
+
? 'untrusted'
|
|
175
|
+
: trustedMatch && !hasUnavailable && rejectedCount === 0 && !truncated
|
|
176
|
+
? 'matched'
|
|
177
|
+
: 'partial';
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
schema: TLSA_EVIDENCE_SCHEMA,
|
|
180
|
+
version: TLSA_EVIDENCE_VERSION,
|
|
181
|
+
state,
|
|
182
|
+
dnssecState,
|
|
183
|
+
pkixValidationState,
|
|
184
|
+
service,
|
|
185
|
+
authorityMaterialCount: authorityMaterials.length,
|
|
186
|
+
authorityMaterialRejectedCount,
|
|
187
|
+
authorityMaterialTruncated,
|
|
188
|
+
records: Object.freeze(results),
|
|
189
|
+
rejectedCount,
|
|
190
|
+
truncated,
|
|
191
|
+
limitations: Object.freeze(limitations),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export { MAX_TLSA_RECORDS, MAX_AUTHORITY_MATERIALS, TLSA_EVIDENCE_SCHEMA, TLSA_EVIDENCE_VERSION, analyzeTlsaEvidence, normalizeTlsaRecord, normalizeTlsaServiceName, };
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { classifyQuery } from "./classify.mjs";
|
|
7
7
|
import { abusechAuthKey } from "./abusech-auth.mjs";
|
|
8
8
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
9
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
10
|
+
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
9
11
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
10
12
|
const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
|
|
11
13
|
const URLHAUS_MAX_RESULTS = 20;
|
|
@@ -53,15 +55,18 @@ function urlhausConfiguration(env = process.env) {
|
|
|
53
55
|
const source = env && typeof env === 'object' ? env : {};
|
|
54
56
|
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLHAUS);
|
|
55
57
|
const authKey = abusechAuthKey(source);
|
|
58
|
+
const policy = providerPolicyAdmission(URLHAUS_PROVIDER.terms, source);
|
|
56
59
|
return {
|
|
57
60
|
enabled,
|
|
58
|
-
configured: enabled && authKey !== null,
|
|
61
|
+
configured: enabled && policy.allowed && authKey !== null,
|
|
59
62
|
authKey: enabled ? authKey : null,
|
|
60
63
|
reason: !enabled
|
|
61
64
|
? 'Malware-host intelligence is not enabled for this deployment.'
|
|
62
|
-
:
|
|
63
|
-
?
|
|
64
|
-
:
|
|
65
|
+
: !policy.allowed
|
|
66
|
+
? policy.reason
|
|
67
|
+
: authKey
|
|
68
|
+
? null
|
|
69
|
+
: 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
function boundedText(value, maxLength) {
|
|
@@ -231,7 +236,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
|
|
|
231
236
|
accept: 'application/json',
|
|
232
237
|
'auth-key': configuration.authKey,
|
|
233
238
|
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
234
|
-
|
|
239
|
+
...whoisleuthRequestHeaders(),
|
|
235
240
|
},
|
|
236
241
|
body,
|
|
237
242
|
signal: AbortSignal.timeout(URLHAUS_PROVIDER.limits.timeoutMs),
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// key; credentials are sent only in a header to the fixed HTTPS endpoint.
|
|
5
5
|
import { classifyQuery } from "./classify.mjs";
|
|
6
6
|
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
7
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
8
|
+
import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
|
|
7
9
|
import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
|
|
8
10
|
const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
|
|
9
11
|
const URLSCAN_SEARCH_DAYS = 90;
|
|
@@ -57,15 +59,18 @@ function urlscanConfiguration(env = process.env) {
|
|
|
57
59
|
const source = env && typeof env === 'object' ? env : {};
|
|
58
60
|
const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLSCAN);
|
|
59
61
|
const apiKey = normalizedApiKey(source.URLSCAN_API_KEY);
|
|
62
|
+
const policy = providerPolicyAdmission(URLSCAN_PROVIDER.terms, source);
|
|
60
63
|
return {
|
|
61
64
|
enabled,
|
|
62
|
-
configured: enabled && apiKey !== null,
|
|
65
|
+
configured: enabled && policy.allowed && apiKey !== null,
|
|
63
66
|
apiKey: enabled ? apiKey : null,
|
|
64
67
|
reason: !enabled
|
|
65
68
|
? 'Archived URLscan verdict search is not enabled for this deployment.'
|
|
66
|
-
:
|
|
67
|
-
?
|
|
68
|
-
:
|
|
69
|
+
: !policy.allowed
|
|
70
|
+
? policy.reason
|
|
71
|
+
: apiKey
|
|
72
|
+
? null
|
|
73
|
+
: 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
function boundedText(value, maxLength) {
|
|
@@ -230,7 +235,7 @@ function createUrlscanIntelligenceAdapter(dependencies = {}) {
|
|
|
230
235
|
headers: {
|
|
231
236
|
accept: 'application/json',
|
|
232
237
|
'api-key': configuration.apiKey,
|
|
233
|
-
|
|
238
|
+
...whoisleuthRequestHeaders(),
|
|
234
239
|
},
|
|
235
240
|
signal: AbortSignal.timeout(URLSCAN_PROVIDER.limits.timeoutMs),
|
|
236
241
|
}, { maxRedirects: 0 });
|
|
@@ -91,8 +91,15 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
|
|
|
91
91
|
detail: () => "The effective selected script source included 'unsafe-eval'.",
|
|
92
92
|
},
|
|
93
93
|
csp_unsafe_inline: {
|
|
94
|
-
label: 'CSP permits unqualified inline script',
|
|
95
|
-
detail: () => "The
|
|
94
|
+
label: 'Response CSP header permits unqualified inline script',
|
|
95
|
+
detail: () => "The selected response header included 'unsafe-inline' without a nonce or hash source in that directive. A separate page policy was not observed early enough to qualify this response-scoped finding.",
|
|
96
|
+
},
|
|
97
|
+
csp_inline_constrained_by_meta: {
|
|
98
|
+
label: 'Page CSP further constrains inline script',
|
|
99
|
+
detail: () => 'A bounded CSP meta policy appeared before page scripts and further constrained the inline-script allowance in the selected response header. Browsers enforce both policies.',
|
|
100
|
+
state: 'observed',
|
|
101
|
+
tone: 'configured',
|
|
102
|
+
evidence: ['Selected HTTP response headers', 'Static page CSP meta policy'],
|
|
96
103
|
},
|
|
97
104
|
hsts_disabled: {
|
|
98
105
|
label: 'HSTS disabled by selected response',
|
|
@@ -125,7 +132,7 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
|
|
|
125
132
|
});
|
|
126
133
|
function responsePolicyFindings(value) {
|
|
127
134
|
const policy = record(value);
|
|
128
|
-
if (policy.responsePolicyVersion
|
|
135
|
+
if (![1, 2].includes(Number(policy.responsePolicyVersion)))
|
|
129
136
|
return [];
|
|
130
137
|
const findings = [];
|
|
131
138
|
const components = record(policy.components);
|
|
@@ -147,7 +154,7 @@ function responsePolicyFindings(value) {
|
|
|
147
154
|
if (!definition || findings.some((item) => item.id === id))
|
|
148
155
|
continue;
|
|
149
156
|
const count = boundedCount(signal.count, 32);
|
|
150
|
-
findings.push(finding(id, 'response headers', 'potential_exposure', 'review', definition.label, definition.detail(count), ['Transient selected response-policy analysis']));
|
|
157
|
+
findings.push(finding(id, 'response headers', definition.state ?? 'potential_exposure', definition.tone ?? 'review', definition.label, definition.detail(count), definition.evidence ?? ['Transient selected response-policy analysis']));
|
|
151
158
|
}
|
|
152
159
|
return findings;
|
|
153
160
|
}
|
package/lib/whois-chain.mjs
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// WHOIS referral-chain orchestration. Transport remains isolated in
|
|
2
|
-
// whois-transport.mts; this module applies registry query profiles
|
|
3
|
-
// bounded referrals
|
|
2
|
+
// whois-transport.mts; this module applies registry query profiles and follows
|
|
3
|
+
// bounded referrals.
|
|
4
4
|
import { domainToUnicode } from 'node:url';
|
|
5
5
|
import { cached } from "./lookup-cache.mjs";
|
|
6
|
-
import { registryCapabilityFor } from "./registry-capabilities.mjs";
|
|
7
|
-
import { readTextCapped, safeFetch } from "./safe-fetch.mjs";
|
|
6
|
+
import { registryCapabilityFor, registryServiceAdmissionFor, } from "./registry-capabilities.mjs";
|
|
8
7
|
import { whoisQuery } from "./whois-transport.mjs";
|
|
9
8
|
const IANA_WHOIS = 'whois.iana.org';
|
|
10
9
|
const WHOIS_HOP_DEADLINE_MS = 12_000;
|
|
11
10
|
const WHOIS_CHAIN_DEADLINE_MS = 25_000;
|
|
12
|
-
const MAX_GT_REGISTRY_HTML_BYTES = 500_000;
|
|
13
11
|
const WHOIS_QUERY_FORMATTERS = {
|
|
14
12
|
'plain-domain': (domain) => domain,
|
|
15
13
|
'denic-domain-ace': (domain) => `-T dn,ace ${domain}`,
|
|
@@ -48,134 +46,19 @@ function extractReferral(whoisText) {
|
|
|
48
46
|
}
|
|
49
47
|
return null;
|
|
50
48
|
}
|
|
51
|
-
function stripTags(html) {
|
|
52
|
-
return html
|
|
53
|
-
.replace(/<[^>]+>/g, ' ')
|
|
54
|
-
.replace(/ /g, ' ')
|
|
55
|
-
.replace(/\s+/g, ' ')
|
|
56
|
-
.trim();
|
|
57
|
-
}
|
|
58
|
-
function sectionBetween(html, startRe, endRes) {
|
|
59
|
-
const startMatch = html.match(startRe);
|
|
60
|
-
if (!startMatch)
|
|
61
|
-
return '';
|
|
62
|
-
const rest = html.slice((startMatch.index ?? 0) + startMatch[0].length);
|
|
63
|
-
let endIndex = rest.length;
|
|
64
|
-
for (const endRe of endRes) {
|
|
65
|
-
const match = rest.match(endRe);
|
|
66
|
-
if (!match)
|
|
67
|
-
continue;
|
|
68
|
-
const matchIndex = match.index ?? 0;
|
|
69
|
-
const index = match[0].startsWith('>') ? matchIndex + 1 : matchIndex;
|
|
70
|
-
if (index < endIndex)
|
|
71
|
-
endIndex = index;
|
|
72
|
-
}
|
|
73
|
-
return rest.slice(0, endIndex);
|
|
74
|
-
}
|
|
75
|
-
function extractIconFields(html, iconMap) {
|
|
76
|
-
let marked = html;
|
|
77
|
-
for (const [icon, key] of Object.entries(iconMap)) {
|
|
78
|
-
marked = marked.replace(new RegExp(`<i[^>]*\\b${icon}\\b[^>]*></i>`, 'gi'), `\n@@${key}@@\n`);
|
|
79
|
-
}
|
|
80
|
-
const parts = stripTags(marked).split(/@@(\w+)@@/);
|
|
81
|
-
const fields = {};
|
|
82
|
-
for (let index = 1; index < parts.length; index += 2) {
|
|
83
|
-
const value = (parts[index + 1] || '').trim();
|
|
84
|
-
const key = parts[index];
|
|
85
|
-
if (key && value && !fields[key])
|
|
86
|
-
fields[key] = value;
|
|
87
|
-
}
|
|
88
|
-
return fields;
|
|
89
|
-
}
|
|
90
|
-
export async function fetchGtRegistryWhois(domain, { fetcher = safeFetch, } = {}) {
|
|
91
|
-
const url = `https://www.gt/sitio/whois.php?dn=${encodeURIComponent(domain)}.&lang=en`;
|
|
92
|
-
const controller = new AbortController();
|
|
93
|
-
const timeout = setTimeout(() => controller.abort(), 8_000);
|
|
94
|
-
try {
|
|
95
|
-
const response = await fetcher(url, {
|
|
96
|
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' },
|
|
97
|
-
signal: controller.signal,
|
|
98
|
-
});
|
|
99
|
-
if (!response.ok) {
|
|
100
|
-
await response.body?.cancel().catch(() => { });
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
const body = await readTextCapped(response, MAX_GT_REGISTRY_HTML_BYTES);
|
|
104
|
-
if (body.truncated)
|
|
105
|
-
return null;
|
|
106
|
-
const html = body.text;
|
|
107
|
-
if (/is not registered/i.test(html))
|
|
108
|
-
return { registered: false };
|
|
109
|
-
const statusMatch = html.match(/<i class="fas fa-bell fa-fw"><\/i>\s*([A-Za-z]+)/i);
|
|
110
|
-
const expiryMatch = html.match(/Expiration:\s*([0-9]{4}-[A-Za-z]{3}-[0-9]{2}[^<]*)/i);
|
|
111
|
-
const orgSection = sectionBetween(html, /Entitled Organization/i, [/Servers\s*<\/h4>/i]);
|
|
112
|
-
const org = extractIconFields(orgSection, {
|
|
113
|
-
'fa-building': 'org',
|
|
114
|
-
'fa-address-card': 'address',
|
|
115
|
-
'fa-phone': 'phone',
|
|
116
|
-
});
|
|
117
|
-
const adminSection = sectionBetween(html, />\s*ADMINISTRATIVE\s*</i, [/>\s*TECHNICAL\s*</i, />\s*BILLING\s*</i]);
|
|
118
|
-
const admin = extractIconFields(adminSection, {
|
|
119
|
-
'fa-user': 'name',
|
|
120
|
-
'fa-envelope': 'email',
|
|
121
|
-
'fa-address-card': 'address',
|
|
122
|
-
'fa-building': 'org',
|
|
123
|
-
});
|
|
124
|
-
const serversSection = sectionBetween(html, /Servers\s*<\/h4>/i, [
|
|
125
|
-
/<div class="span6">/i,
|
|
126
|
-
]);
|
|
127
|
-
const nameservers = [
|
|
128
|
-
...serversSection.matchAll(/<strong>\s*([a-zA-Z0-9.\-]+)\.?\s*<\/strong>/gi),
|
|
129
|
-
]
|
|
130
|
-
.map((match) => match[1]?.trim())
|
|
131
|
-
.filter((value) => Boolean(value));
|
|
132
|
-
return {
|
|
133
|
-
registered: true,
|
|
134
|
-
status: statusMatch?.[1]?.trim() || null,
|
|
135
|
-
expiryDate: expiryMatch?.[1]?.trim() || null,
|
|
136
|
-
registrantOrg: org.org || null,
|
|
137
|
-
registrantAddress: org.address || null,
|
|
138
|
-
registrantPhone: org.phone || null,
|
|
139
|
-
adminName: admin.name || null,
|
|
140
|
-
adminOrg: admin.org || null,
|
|
141
|
-
adminEmail: admin.email || null,
|
|
142
|
-
nameservers,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
finally {
|
|
149
|
-
clearTimeout(timeout);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
function formatGtResultAsText(domain, result) {
|
|
153
|
-
if (!result.registered)
|
|
154
|
-
return `No match for domain ${domain.toUpperCase()}.`;
|
|
155
|
-
const lines = [`Domain Name: ${domain.toUpperCase()}`];
|
|
156
|
-
if (result.status)
|
|
157
|
-
lines.push(`Domain Status: ${result.status}`);
|
|
158
|
-
if (result.expiryDate)
|
|
159
|
-
lines.push(`Registry Expiry Date: ${result.expiryDate}`);
|
|
160
|
-
if (result.registrantOrg)
|
|
161
|
-
lines.push(`Registrant Organization: ${result.registrantOrg}`);
|
|
162
|
-
if (result.registrantAddress)
|
|
163
|
-
lines.push(`Registrant Address: ${result.registrantAddress}`);
|
|
164
|
-
if (result.registrantPhone)
|
|
165
|
-
lines.push(`Registrant Phone: ${result.registrantPhone}`);
|
|
166
|
-
if (result.adminName)
|
|
167
|
-
lines.push(`Admin Name: ${result.adminName}`);
|
|
168
|
-
if (result.adminOrg)
|
|
169
|
-
lines.push(`Admin Organization: ${result.adminOrg}`);
|
|
170
|
-
if (result.adminEmail)
|
|
171
|
-
lines.push(`Admin Email: ${result.adminEmail}`);
|
|
172
|
-
for (const nameserver of result.nameservers)
|
|
173
|
-
lines.push(`Name Server: ${nameserver}`);
|
|
174
|
-
return lines.join('\n');
|
|
175
|
-
}
|
|
176
49
|
export async function buildWhoisChainUncached(query, options = {}) {
|
|
50
|
+
const admission = registryServiceAdmissionFor(query, 'whois');
|
|
51
|
+
if (admission && !admission.allowed) {
|
|
52
|
+
return [{
|
|
53
|
+
server: 'registry capability policy',
|
|
54
|
+
queryProfile: 'not-issued',
|
|
55
|
+
responseEncoding: 'utf-8',
|
|
56
|
+
error: admission.state === 'permission_required'
|
|
57
|
+
? 'WHOIS collection requires registry permission or source authorization; no socket was opened.'
|
|
58
|
+
: 'IANA publishes no domain WHOIS service for this suffix; no socket was opened.',
|
|
59
|
+
}];
|
|
60
|
+
}
|
|
177
61
|
const queryWhois = options.whoisQuery || whoisQuery;
|
|
178
|
-
const fetchGtWhois = options.fetchGtRegistryWhois || fetchGtRegistryWhois;
|
|
179
62
|
const now = options.now || Date.now;
|
|
180
63
|
const chainDeadlineMs = options.chainDeadlineMs || WHOIS_CHAIN_DEADLINE_MS;
|
|
181
64
|
const chain = [];
|
|
@@ -233,27 +116,6 @@ export async function buildWhoisChainUncached(query, options = {}) {
|
|
|
233
116
|
break;
|
|
234
117
|
currentServer = referral;
|
|
235
118
|
}
|
|
236
|
-
const firstHop = chain[0];
|
|
237
|
-
if (query.toLowerCase().endsWith('.gt') &&
|
|
238
|
-
chain.length === 1 &&
|
|
239
|
-
firstHop &&
|
|
240
|
-
!('error' in firstHop)) {
|
|
241
|
-
try {
|
|
242
|
-
const result = await fetchGtWhois(query);
|
|
243
|
-
if (result) {
|
|
244
|
-
chain.push({
|
|
245
|
-
server: 'www.gt (registry website - .gt has no WHOIS:43 server)',
|
|
246
|
-
queriedAt: new Date().toISOString(),
|
|
247
|
-
queryProfile: 'gt-registry-web',
|
|
248
|
-
responseEncoding: 'utf-8',
|
|
249
|
-
response: formatGtResultAsText(query, result),
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
// Best-effort registry fallback. The IANA hop remains explicit on failure.
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
119
|
return chain;
|
|
258
120
|
}
|
|
259
121
|
export async function buildWhoisChain(query) {
|
package/lib/whois.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { domainToASCII } from 'node:url';
|
|
5
5
|
import { registryDateIso } from "./registry-dates.mjs";
|
|
6
6
|
import { queryWhoisAddress, whoisQuery, } from "./whois-transport.mjs";
|
|
7
|
-
import { buildWhoisChain, buildWhoisChainUncached,
|
|
7
|
+
import { buildWhoisChain, buildWhoisChainUncached, } from "./whois-chain.mjs";
|
|
8
8
|
import { analyzeWhoisChainAuthority, hasNicKgRegistrationEvidence, } from "./whois-authority.mjs";
|
|
9
9
|
import { parseIndentedContactBlock, resolveFredContact, resolveIrnicContact, resolveIsnicRole, } from "./whois-contacts.mjs";
|
|
10
10
|
import { normalizeWhoisChain } from "./whois-normalization.mjs";
|
|
@@ -1184,4 +1184,4 @@ function parseWhoisChain(chain) {
|
|
|
1184
1184
|
...authority,
|
|
1185
1185
|
};
|
|
1186
1186
|
}
|
|
1187
|
-
export { buildWhoisChain, parseWhoisChain, analyzeWhoisChainAuthority, whoisQuery, queryWhoisAddress, buildWhoisChainUncached,
|
|
1187
|
+
export { buildWhoisChain, parseWhoisChain, analyzeWhoisChainAuthority, whoisQuery, queryWhoisAddress, buildWhoisChainUncached, };
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"name": "@slicedearth/whoisleuth-cli",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
|
+
"author": "slicedearth",
|
|
5
6
|
"contentPolicy": {
|
|
6
7
|
"class": "dual-use"
|
|
7
8
|
},
|
|
@@ -26,17 +27,17 @@
|
|
|
26
27
|
},
|
|
27
28
|
"repository": {
|
|
28
29
|
"type": "git",
|
|
29
|
-
"url": "https://github.com/slicedearth/whoisleuth.git"
|
|
30
|
+
"url": "git+https://github.com/slicedearth/whoisleuth.git"
|
|
30
31
|
},
|
|
31
32
|
"homepage": "https://whoisleuth.com/",
|
|
32
33
|
"bugs": {
|
|
33
34
|
"url": "https://github.com/slicedearth/whoisleuth/issues"
|
|
34
35
|
},
|
|
35
|
-
"version": "1.
|
|
36
|
+
"version": "1.36.1",
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"parse5": "
|
|
38
|
-
"tldts": "
|
|
39
|
-
"undici": "
|
|
38
|
+
"parse5": "8.0.1",
|
|
39
|
+
"tldts": "7.4.10",
|
|
40
|
+
"undici": "8.9.0"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
42
43
|
"bin/**/*.mjs",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"LICENSES/*.txt",
|
|
50
51
|
"NOTICE",
|
|
51
52
|
"README.md",
|
|
53
|
+
"SECURITY.md",
|
|
52
54
|
"TRADEMARKS.md",
|
|
53
55
|
"third-party-notices.txt"
|
|
54
56
|
],
|
package/third-party-notices.txt
CHANGED
|
@@ -5130,7 +5130,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
|
5130
5130
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
5131
5131
|
DEALINGS IN THE SOFTWARE.
|
|
5132
5132
|
================================================================================
|
|
5133
|
-
tldts@7.4.
|
|
5133
|
+
tldts@7.4.10
|
|
5134
5134
|
Relationship: direct production dependency
|
|
5135
5135
|
Declared licence: MIT
|
|
5136
5136
|
Licence source: LICENSE
|
|
@@ -5149,7 +5149,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
|
|
5149
5149
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
5150
5150
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
5151
5151
|
================================================================================
|
|
5152
|
-
tldts-core@7.4.
|
|
5152
|
+
tldts-core@7.4.10
|
|
5153
5153
|
Relationship: transitive production dependency
|
|
5154
5154
|
Declared licence: MIT
|
|
5155
5155
|
Licence source: LICENSE
|