@slicedearth/whoisleuth-cli 1.32.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 +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Bounded interpretation of already-collected registry and WHOIS evidence.
|
|
2
|
+
//
|
|
3
|
+
// This module performs no network work. It keeps raw source statuses alongside
|
|
4
|
+
// conservative lifecycle, disclosure, reconciliation, publication-quality,
|
|
5
|
+
// and abuse-routing summaries so a missing field never becomes a claim that
|
|
6
|
+
// contact data or a registration condition is absent.
|
|
7
|
+
import { compareRegistrySources } from "./registry-comparison.mjs";
|
|
8
|
+
import { inspectRdapCapabilities } from "./rdap-capabilities.mjs";
|
|
9
|
+
export const REGISTRY_INSIGHTS_VERSION = 1;
|
|
10
|
+
export const MAX_REGISTRY_INSIGHT_STATUSES = 40;
|
|
11
|
+
export const MAX_REGISTRY_INSIGHT_ISSUES = 12;
|
|
12
|
+
export const MAX_REGISTRY_ABUSE_ROUTES = 8;
|
|
13
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
14
|
+
const PRIVACY_PROXY_RE = /(?:privacy|proxy|whoisguard|data protected|identity protect|private registration)/iu;
|
|
15
|
+
const REDACTED_RE = /(?:redacted|masked|not published)/iu;
|
|
16
|
+
const WITHHELD_RE = /(?:withheld|not disclosed|restricted disclosure)/iu;
|
|
17
|
+
function record(value) {
|
|
18
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
19
|
+
? value
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function text(value, maximum = 320) {
|
|
26
|
+
return typeof value === 'string' && !CONTROL_RE.test(value)
|
|
27
|
+
? value.trim().slice(0, maximum)
|
|
28
|
+
: '';
|
|
29
|
+
}
|
|
30
|
+
function strings(value, maximum = MAX_REGISTRY_INSIGHT_STATUSES) {
|
|
31
|
+
if (!Array.isArray(value))
|
|
32
|
+
return [];
|
|
33
|
+
const unique = new Set();
|
|
34
|
+
for (const item of value.slice(0, maximum * 2)) {
|
|
35
|
+
const normalized = text(item, 160);
|
|
36
|
+
if (normalized)
|
|
37
|
+
unique.add(normalized);
|
|
38
|
+
if (unique.size >= maximum)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return [...unique];
|
|
42
|
+
}
|
|
43
|
+
function normalizedStatus(value) {
|
|
44
|
+
return text(value, 160).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
45
|
+
}
|
|
46
|
+
function sourceState(status, partial) {
|
|
47
|
+
if (partial || status === 'partial')
|
|
48
|
+
return 'partial';
|
|
49
|
+
return typeof status === 'string'
|
|
50
|
+
&& ['success', 'complete'].includes(status)
|
|
51
|
+
? 'complete'
|
|
52
|
+
: 'unavailable';
|
|
53
|
+
}
|
|
54
|
+
function redactionBlob(parsed) {
|
|
55
|
+
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 50) : [];
|
|
56
|
+
return redactions.map((item) => {
|
|
57
|
+
const value = record(item);
|
|
58
|
+
return [
|
|
59
|
+
value.name,
|
|
60
|
+
value.reason,
|
|
61
|
+
value.method,
|
|
62
|
+
value.prePath,
|
|
63
|
+
value.postPath,
|
|
64
|
+
value.replacementPath,
|
|
65
|
+
].map((part) => text(part, 240)).join(' ');
|
|
66
|
+
}).join(' ');
|
|
67
|
+
}
|
|
68
|
+
function contactBlob(contact) {
|
|
69
|
+
const value = record(contact);
|
|
70
|
+
return [
|
|
71
|
+
value.name,
|
|
72
|
+
value.org,
|
|
73
|
+
value.email,
|
|
74
|
+
value.phone,
|
|
75
|
+
value.address,
|
|
76
|
+
...(Array.isArray(value.names) ? value.names.slice(0, 10) : []),
|
|
77
|
+
...(Array.isArray(value.organizations) ? value.organizations.slice(0, 10) : []),
|
|
78
|
+
...(Array.isArray(value.emails) ? value.emails.slice(0, 10) : []),
|
|
79
|
+
].map((part) => text(part, 320)).filter(Boolean).join(' ');
|
|
80
|
+
}
|
|
81
|
+
function disclosure(source, parsedValue, status) {
|
|
82
|
+
if (!isRecord(parsedValue)) {
|
|
83
|
+
return {
|
|
84
|
+
source,
|
|
85
|
+
state: 'unavailable',
|
|
86
|
+
detail: 'The source did not provide a usable normalized publication.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const parsed = record(parsedValue);
|
|
90
|
+
const partial = source === 'whois'
|
|
91
|
+
? parsed.chainStatus === 'partial' || strings(parsed.fieldsTruncated, 20).length > 0
|
|
92
|
+
: parsed.serverTruncated === true
|
|
93
|
+
|| parsed.entitiesTruncated === true
|
|
94
|
+
|| parsed.statusesTruncated === true;
|
|
95
|
+
const publicationState = sourceState(status, partial);
|
|
96
|
+
if (publicationState === 'unavailable') {
|
|
97
|
+
return {
|
|
98
|
+
source,
|
|
99
|
+
state: 'unavailable',
|
|
100
|
+
detail: 'The source was unavailable, skipped, unsupported, or did not return a usable publication.',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const registrant = source === 'registry_rdap'
|
|
104
|
+
? contactBlob(parsed.registrant)
|
|
105
|
+
: [
|
|
106
|
+
parsed.registrantName,
|
|
107
|
+
parsed.registrantOrg,
|
|
108
|
+
parsed.registrantEmail,
|
|
109
|
+
parsed.registrantPhone,
|
|
110
|
+
parsed.registrantAddress,
|
|
111
|
+
].map((part) => text(part, 320)).filter(Boolean).join(' ');
|
|
112
|
+
const redactions = redactionBlob(parsed);
|
|
113
|
+
const combined = `${registrant} ${redactions}`.trim();
|
|
114
|
+
if (WITHHELD_RE.test(combined)) {
|
|
115
|
+
return { source, state: 'withheld', detail: 'The publication indicates that registrant data was withheld or not disclosed.' };
|
|
116
|
+
}
|
|
117
|
+
if (REDACTED_RE.test(combined) || redactions) {
|
|
118
|
+
return { source, state: 'redacted', detail: 'The publication explicitly marks registrant data as redacted or removed.' };
|
|
119
|
+
}
|
|
120
|
+
if (PRIVACY_PROXY_RE.test(registrant)) {
|
|
121
|
+
return { source, state: 'privacy_proxy', detail: 'The published registrant fields appear to identify a privacy or proxy service.' };
|
|
122
|
+
}
|
|
123
|
+
if (registrant) {
|
|
124
|
+
return { source, state: 'public', detail: 'The source published at least one usable registrant contact field.' };
|
|
125
|
+
}
|
|
126
|
+
if (publicationState === 'partial') {
|
|
127
|
+
return {
|
|
128
|
+
source,
|
|
129
|
+
state: 'unavailable',
|
|
130
|
+
detail: 'The publication was partial, so missing registrant fields are not treated as evidence of absence.',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
source,
|
|
135
|
+
state: 'absent',
|
|
136
|
+
detail: 'The source completed without a usable registrant contact or an explicit disclosure marker.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function lifecycle(statusesValue) {
|
|
140
|
+
const rawStatuses = strings(statusesValue);
|
|
141
|
+
const normalized = rawStatuses.map(normalizedStatus);
|
|
142
|
+
const has = (value) => normalized.includes(value);
|
|
143
|
+
const pendingDelete = has('pendingdelete');
|
|
144
|
+
const redemption = has('redemptionperiod');
|
|
145
|
+
const pendingTransfer = has('pendingtransfer');
|
|
146
|
+
const serverHold = has('serverhold');
|
|
147
|
+
const clientHold = has('clienthold');
|
|
148
|
+
const serverLocks = normalized.filter((value) => /^server(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
149
|
+
const clientLocks = normalized.filter((value) => /^client(?:delete|renew|transfer|update)prohibited$/u.test(value));
|
|
150
|
+
let stage = 'registered';
|
|
151
|
+
let label = 'Registered';
|
|
152
|
+
let acquisitionPath = ['Continue monitoring registry status and expiry.'];
|
|
153
|
+
if (pendingDelete) {
|
|
154
|
+
stage = 'pending_delete';
|
|
155
|
+
label = 'Pending delete';
|
|
156
|
+
acquisitionPath = [
|
|
157
|
+
'Confirm the status from the authoritative registry publication.',
|
|
158
|
+
'Monitor for deletion without assuming a release time or successful registration.',
|
|
159
|
+
'Use a registrar or registry-supported acquisition path when policy permits.',
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
else if (redemption) {
|
|
163
|
+
stage = 'redemption';
|
|
164
|
+
label = 'Redemption period';
|
|
165
|
+
acquisitionPath = [
|
|
166
|
+
'The current registrant may still be able to restore the registration.',
|
|
167
|
+
'Monitor for restoration or a later pending-delete transition.',
|
|
168
|
+
'Do not treat the domain as available or guaranteed to drop.',
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
else if (pendingTransfer) {
|
|
172
|
+
stage = 'pending_transfer';
|
|
173
|
+
label = 'Pending transfer';
|
|
174
|
+
acquisitionPath = ['A registrar transfer is pending; this is not an availability signal.'];
|
|
175
|
+
}
|
|
176
|
+
else if (serverHold || clientHold) {
|
|
177
|
+
stage = 'hold';
|
|
178
|
+
label = 'Registration hold';
|
|
179
|
+
acquisitionPath = ['A hold affects delegation or publication; it does not mean the registration is available.'];
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
stage,
|
|
183
|
+
label,
|
|
184
|
+
rawStatuses,
|
|
185
|
+
redemption,
|
|
186
|
+
pendingDelete,
|
|
187
|
+
pendingTransfer,
|
|
188
|
+
hold: { client: clientHold, server: serverHold },
|
|
189
|
+
locks: {
|
|
190
|
+
client: clientLocks.length > 0,
|
|
191
|
+
server: serverLocks.length > 0,
|
|
192
|
+
clientStatuses: clientLocks,
|
|
193
|
+
serverStatuses: serverLocks,
|
|
194
|
+
},
|
|
195
|
+
acquisitionPath,
|
|
196
|
+
limitation: 'EPP statuses describe the current registry lifecycle. They do not guarantee deletion, release timing, transfer completion, eligibility, price, or acquisition success.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
200
|
+
const usablePublication = isRecord(parsedValue);
|
|
201
|
+
const parsed = record(parsedValue);
|
|
202
|
+
const partial = parsed.serverTruncated === true
|
|
203
|
+
|| parsed.chainStatus === 'partial'
|
|
204
|
+
|| parsed.entitiesTruncated === true
|
|
205
|
+
|| parsed.statusesTruncated === true
|
|
206
|
+
|| strings(parsed.fieldsTruncated, 20).length > 0;
|
|
207
|
+
const state = usablePublication ? sourceState(status, partial) : 'unavailable';
|
|
208
|
+
const issues = [];
|
|
209
|
+
if (state === 'unavailable')
|
|
210
|
+
issues.push('No usable publication was available from this source.');
|
|
211
|
+
if (parsed.serverTruncated === true)
|
|
212
|
+
issues.push('The RDAP server declared a truncated response.');
|
|
213
|
+
if (parsed.entitiesTruncated === true)
|
|
214
|
+
issues.push('The normalized entity inventory was capped.');
|
|
215
|
+
if (parsed.statusesTruncated === true)
|
|
216
|
+
issues.push('The normalized status inventory was capped.');
|
|
217
|
+
if (strings(parsed.fieldsTruncated, 20).length)
|
|
218
|
+
issues.push('One or more normalized WHOIS fields were capped.');
|
|
219
|
+
if (parsed.chainStatus === 'partial')
|
|
220
|
+
issues.push('The WHOIS referral chain was incomplete or conflicting.');
|
|
221
|
+
const conformance = strings(parsed.conformance, 30);
|
|
222
|
+
const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 50) : [];
|
|
223
|
+
return {
|
|
224
|
+
source,
|
|
225
|
+
state,
|
|
226
|
+
observedAt: text(observedAt, 64) || null,
|
|
227
|
+
conformance,
|
|
228
|
+
redactionCount: redactions.length,
|
|
229
|
+
issueCount: Math.min(issues.length, MAX_REGISTRY_INSIGHT_ISSUES),
|
|
230
|
+
issues: issues.slice(0, MAX_REGISTRY_INSIGHT_ISSUES),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function contactRoute(kind, contact, source) {
|
|
234
|
+
const value = record(contact);
|
|
235
|
+
return [
|
|
236
|
+
{ channel: 'email', contact: text(value.email, 320) },
|
|
237
|
+
{ channel: 'phone', contact: text(value.phone, 80) },
|
|
238
|
+
].filter((item) => item.contact).map((item) => ({
|
|
239
|
+
kind,
|
|
240
|
+
contact: item.contact,
|
|
241
|
+
channel: item.channel,
|
|
242
|
+
source,
|
|
243
|
+
limitations: [
|
|
244
|
+
'A published contact does not prove that the destination is monitored, appropriate for the incident, or responsible for the observed content.',
|
|
245
|
+
],
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
export function buildRegistryInsights(input) {
|
|
249
|
+
const rdap = record(input.rdapParsed);
|
|
250
|
+
const whois = record(input.whoisParsed);
|
|
251
|
+
const registrarRdap = record(input.registrarRdapParsed);
|
|
252
|
+
const comparison = compareRegistrySources(rdap, whois, {
|
|
253
|
+
rdapStatus: input.rdapStatus,
|
|
254
|
+
whoisStatus: input.whoisStatus,
|
|
255
|
+
});
|
|
256
|
+
const counts = comparison.counts;
|
|
257
|
+
const conflictCount = counts.conflict;
|
|
258
|
+
const sourceOnlyCount = counts.rdap_only + counts.whois_only;
|
|
259
|
+
const redactedCount = counts.rdap_redacted + counts.whois_redacted;
|
|
260
|
+
const unavailableCount = counts.rdap_unavailable
|
|
261
|
+
+ counts.whois_unavailable
|
|
262
|
+
+ counts.rdap_incomplete
|
|
263
|
+
+ counts.whois_incomplete;
|
|
264
|
+
const reconciliationState = conflictCount
|
|
265
|
+
? 'conflict'
|
|
266
|
+
: unavailableCount
|
|
267
|
+
? 'partial'
|
|
268
|
+
: sourceOnlyCount || redactedCount
|
|
269
|
+
? 'source_specific'
|
|
270
|
+
: counts.equivalent
|
|
271
|
+
? 'consistent'
|
|
272
|
+
: 'unavailable';
|
|
273
|
+
const statuses = strings(rdap.statuses).length ? rdap.statuses : whois.statuses;
|
|
274
|
+
const routes = [
|
|
275
|
+
...contactRoute('registry', rdap.abuse, 'Registry RDAP abuse entity'),
|
|
276
|
+
...contactRoute('registrar', rdap.registrar, 'Registry RDAP registrar entity'),
|
|
277
|
+
...contactRoute('registrar', registrarRdap.abuse, 'Registrar RDAP abuse entity'),
|
|
278
|
+
...contactRoute('registrar', registrarRdap.registrar, 'Registrar RDAP registrar entity'),
|
|
279
|
+
...contactRoute('registrar', { email: whois.abuseEmail, phone: whois.abusePhone }, 'WHOIS registrar abuse fields'),
|
|
280
|
+
];
|
|
281
|
+
const uniqueRoutes = new Map();
|
|
282
|
+
for (const route of routes) {
|
|
283
|
+
const key = `${route.kind}\u0000${route.channel}\u0000${route.contact.toLowerCase()}`;
|
|
284
|
+
if (!uniqueRoutes.has(key))
|
|
285
|
+
uniqueRoutes.set(key, route);
|
|
286
|
+
if (uniqueRoutes.size >= MAX_REGISTRY_ABUSE_ROUTES)
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
version: REGISTRY_INSIGHTS_VERSION,
|
|
291
|
+
contactDisclosure: {
|
|
292
|
+
registryRdap: disclosure('registry_rdap', input.rdapParsed, input.rdapStatus),
|
|
293
|
+
whois: disclosure('whois', input.whoisParsed, input.whoisStatus),
|
|
294
|
+
limitation: 'Disclosure state describes what each point-in-time source published. It does not infer the identity, intent, reachability, or legal status of a registrant.',
|
|
295
|
+
},
|
|
296
|
+
lifecycle: lifecycle(statuses),
|
|
297
|
+
reconciliation: {
|
|
298
|
+
state: reconciliationState,
|
|
299
|
+
conflictCount,
|
|
300
|
+
equivalentCount: counts.equivalent,
|
|
301
|
+
sourceOnlyCount,
|
|
302
|
+
redactedCount,
|
|
303
|
+
unavailableCount,
|
|
304
|
+
summary: conflictCount
|
|
305
|
+
? `${conflictCount} normalized registry conflict${conflictCount === 1 ? '' : 's'} require source review.`
|
|
306
|
+
: unavailableCount
|
|
307
|
+
? 'The comparison is partial because at least one publication was unavailable or incomplete.'
|
|
308
|
+
: sourceOnlyCount || redactedCount
|
|
309
|
+
? 'The sources differ in publication or disclosure without a normalized conflict.'
|
|
310
|
+
: counts.equivalent
|
|
311
|
+
? 'The comparable fields are normalized as equivalent.'
|
|
312
|
+
: 'No comparable fields were available.',
|
|
313
|
+
},
|
|
314
|
+
publications: [
|
|
315
|
+
publicationDiagnostic('registry_rdap', input.rdapParsed, input.rdapStatus, input.rdapFetchedAt),
|
|
316
|
+
publicationDiagnostic('whois', input.whoisParsed, input.whoisStatus, input.whoisQueriedAt),
|
|
317
|
+
publicationDiagnostic('registrar_rdap', input.registrarRdapParsed, input.registrarRdapStatus, input.registrarRdapFetchedAt),
|
|
318
|
+
],
|
|
319
|
+
rdapCapabilities: {
|
|
320
|
+
registry: inspectRdapCapabilities(input.rdapParsed, input.rdapStatus),
|
|
321
|
+
registrar: inspectRdapCapabilities(input.registrarRdapParsed, input.registrarRdapStatus),
|
|
322
|
+
},
|
|
323
|
+
abuseRouting: [...uniqueRoutes.values()],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Privacy-minimized interpretation of selected response policies. Header and
|
|
2
|
+
// cookie values are read transiently from the already-selected homepage
|
|
3
|
+
// response and reduced to fixed signal identifiers plus bounded counts.
|
|
4
|
+
export const RESPONSE_POLICY_VERSION = 1;
|
|
5
|
+
export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
|
|
6
|
+
export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
|
|
7
|
+
export const MAX_RESPONSE_POLICY_TOKENS = 128;
|
|
8
|
+
export const MAX_RESPONSE_COOKIES = 32;
|
|
9
|
+
export const MAX_RESPONSE_COOKIE_BYTES = 4 * 1024;
|
|
10
|
+
export const MAX_RESPONSE_COOKIE_TOTAL_BYTES = 32 * 1024;
|
|
11
|
+
export const MIN_RECOMMENDED_HSTS_SECONDS = 180 * 24 * 60 * 60;
|
|
12
|
+
const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
13
|
+
const DIRECTIVE_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
14
|
+
const ATTRIBUTE_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$/;
|
|
15
|
+
const CSP_NONCE_OR_HASH_RE = /^'(?:nonce-[^']+|sha(?:256|384|512)-[^']+)'$/i;
|
|
16
|
+
const REFERRER_POLICIES = new Set([
|
|
17
|
+
'no-referrer',
|
|
18
|
+
'no-referrer-when-downgrade',
|
|
19
|
+
'origin',
|
|
20
|
+
'origin-when-cross-origin',
|
|
21
|
+
'same-origin',
|
|
22
|
+
'strict-origin',
|
|
23
|
+
'strict-origin-when-cross-origin',
|
|
24
|
+
'unsafe-url',
|
|
25
|
+
]);
|
|
26
|
+
const PERMISSIVE_REFERRER_POLICIES = new Set(['no-referrer-when-downgrade', 'unsafe-url']);
|
|
27
|
+
function boundedCount(value, maximum) {
|
|
28
|
+
return Math.max(0, Math.min(maximum, Math.trunc(value)));
|
|
29
|
+
}
|
|
30
|
+
function readHeader(headers, name) {
|
|
31
|
+
if (!headers || typeof headers.get !== 'function')
|
|
32
|
+
return { state: 'absent', value: null };
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = headers.get(name);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { state: 'malformed', value: null };
|
|
39
|
+
}
|
|
40
|
+
if (raw === null || raw.trim() === '')
|
|
41
|
+
return { state: 'absent', value: null };
|
|
42
|
+
if (CONTROL_RE.test(raw))
|
|
43
|
+
return { state: 'malformed', value: null };
|
|
44
|
+
if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_POLICY_HEADER_BYTES) {
|
|
45
|
+
return { state: 'partial', value: null };
|
|
46
|
+
}
|
|
47
|
+
return { state: 'present', value: raw.trim() };
|
|
48
|
+
}
|
|
49
|
+
function addSignal(signals, id, count) {
|
|
50
|
+
if (signals.some((signal) => signal.id === id))
|
|
51
|
+
return;
|
|
52
|
+
signals.push(count === undefined ? { id } : { id, count: boundedCount(count, MAX_RESPONSE_COOKIES) });
|
|
53
|
+
}
|
|
54
|
+
function unavailableHeaderState(header) {
|
|
55
|
+
return header.state === 'present' ? 'malformed' : header.state;
|
|
56
|
+
}
|
|
57
|
+
function analyzeCsp(header, signals) {
|
|
58
|
+
if (header.state !== 'present' || !header.value) {
|
|
59
|
+
return unavailableHeaderState(header);
|
|
60
|
+
}
|
|
61
|
+
const directives = new Map();
|
|
62
|
+
let tokenCount = 0;
|
|
63
|
+
let partial = false;
|
|
64
|
+
for (const rawDirective of header.value.split(';')) {
|
|
65
|
+
const normalized = rawDirective.trim();
|
|
66
|
+
if (!normalized)
|
|
67
|
+
continue;
|
|
68
|
+
const [rawName = '', ...rawTokens] = normalized.split(/\s+/u);
|
|
69
|
+
const name = rawName.toLowerCase();
|
|
70
|
+
if (!DIRECTIVE_NAME_RE.test(name))
|
|
71
|
+
return 'malformed';
|
|
72
|
+
if (directives.has(name))
|
|
73
|
+
continue;
|
|
74
|
+
if (directives.size >= MAX_RESPONSE_POLICY_DIRECTIVES) {
|
|
75
|
+
partial = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
const remaining = Math.max(0, MAX_RESPONSE_POLICY_TOKENS - tokenCount);
|
|
79
|
+
const tokens = rawTokens.slice(0, remaining).map((token) => token.toLowerCase());
|
|
80
|
+
if (tokens.length !== rawTokens.length)
|
|
81
|
+
partial = true;
|
|
82
|
+
tokenCount += tokens.length;
|
|
83
|
+
directives.set(name, tokens);
|
|
84
|
+
}
|
|
85
|
+
if (!directives.size)
|
|
86
|
+
return 'malformed';
|
|
87
|
+
const defaultSources = directives.get('default-src');
|
|
88
|
+
const scriptSources = directives.get('script-src') || defaultSources;
|
|
89
|
+
if (!defaultSources)
|
|
90
|
+
addSignal(signals, 'csp_default_source_missing');
|
|
91
|
+
if (!directives.has('base-uri'))
|
|
92
|
+
addSignal(signals, 'csp_base_uri_missing');
|
|
93
|
+
if (!directives.has('object-src') && !defaultSources)
|
|
94
|
+
addSignal(signals, 'csp_object_source_unbounded');
|
|
95
|
+
if (scriptSources) {
|
|
96
|
+
if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
|
|
97
|
+
addSignal(signals, 'csp_permissive_script_source');
|
|
98
|
+
}
|
|
99
|
+
if (scriptSources.includes("'unsafe-eval'"))
|
|
100
|
+
addSignal(signals, 'csp_unsafe_eval');
|
|
101
|
+
if (scriptSources.includes("'unsafe-inline'")
|
|
102
|
+
&& !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
|
|
103
|
+
addSignal(signals, 'csp_unsafe_inline');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return partial ? 'partial' : 'parsed';
|
|
107
|
+
}
|
|
108
|
+
function analyzeHsts(header, signals) {
|
|
109
|
+
if (header.state !== 'present' || !header.value) {
|
|
110
|
+
return unavailableHeaderState(header);
|
|
111
|
+
}
|
|
112
|
+
const directives = header.value.split(';').map((part) => part.trim()).filter(Boolean);
|
|
113
|
+
if (directives.length > MAX_RESPONSE_POLICY_DIRECTIVES)
|
|
114
|
+
return 'partial';
|
|
115
|
+
const maxAge = directives
|
|
116
|
+
.map((part) => part.match(/^max-age\s*=\s*(\d+)$/iu)?.[1] || null)
|
|
117
|
+
.find((value) => value !== null);
|
|
118
|
+
if (!maxAge || maxAge.length > 12)
|
|
119
|
+
return 'malformed';
|
|
120
|
+
const seconds = Number(maxAge);
|
|
121
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0)
|
|
122
|
+
return 'malformed';
|
|
123
|
+
if (seconds === 0)
|
|
124
|
+
addSignal(signals, 'hsts_disabled');
|
|
125
|
+
else if (seconds < MIN_RECOMMENDED_HSTS_SECONDS)
|
|
126
|
+
addSignal(signals, 'hsts_short_max_age');
|
|
127
|
+
return 'parsed';
|
|
128
|
+
}
|
|
129
|
+
function analyzeReferrerPolicy(header, signals) {
|
|
130
|
+
if (header.state !== 'present' || !header.value) {
|
|
131
|
+
return unavailableHeaderState(header);
|
|
132
|
+
}
|
|
133
|
+
const candidates = header.value
|
|
134
|
+
.split(',')
|
|
135
|
+
.map((item) => item.trim().toLowerCase())
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.slice(0, MAX_RESPONSE_POLICY_TOKENS);
|
|
138
|
+
const effective = candidates.filter((candidate) => REFERRER_POLICIES.has(candidate)).at(-1);
|
|
139
|
+
if (!effective)
|
|
140
|
+
return 'malformed';
|
|
141
|
+
if (PERMISSIVE_REFERRER_POLICIES.has(effective)) {
|
|
142
|
+
addSignal(signals, 'referrer_policy_permissive');
|
|
143
|
+
}
|
|
144
|
+
return candidates.length >= MAX_RESPONSE_POLICY_TOKENS ? 'partial' : 'parsed';
|
|
145
|
+
}
|
|
146
|
+
function responseCookies(headers) {
|
|
147
|
+
if (!headers)
|
|
148
|
+
return { state: 'absent', values: [], truncated: false };
|
|
149
|
+
let values = [];
|
|
150
|
+
try {
|
|
151
|
+
const combined = typeof headers.getSetCookie === 'function' ? null : headers.get('set-cookie');
|
|
152
|
+
values = typeof headers.getSetCookie === 'function'
|
|
153
|
+
? headers.getSetCookie()
|
|
154
|
+
: combined
|
|
155
|
+
? [combined]
|
|
156
|
+
: [];
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { state: 'malformed', values: [], truncated: false };
|
|
160
|
+
}
|
|
161
|
+
if (!values.length)
|
|
162
|
+
return { state: 'absent', values: [], truncated: false };
|
|
163
|
+
const retained = [];
|
|
164
|
+
let totalBytes = 0;
|
|
165
|
+
let truncated = values.length > MAX_RESPONSE_COOKIES;
|
|
166
|
+
for (const value of values.slice(0, MAX_RESPONSE_COOKIES)) {
|
|
167
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
168
|
+
return { state: 'malformed', values: [], truncated };
|
|
169
|
+
const bytes = new TextEncoder().encode(value).byteLength;
|
|
170
|
+
if (bytes > MAX_RESPONSE_COOKIE_BYTES || totalBytes + bytes > MAX_RESPONSE_COOKIE_TOTAL_BYTES) {
|
|
171
|
+
truncated = true;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
totalBytes += bytes;
|
|
175
|
+
retained.push(value);
|
|
176
|
+
}
|
|
177
|
+
return { state: truncated ? 'partial' : 'parsed', values: retained, truncated };
|
|
178
|
+
}
|
|
179
|
+
function analyzeCookies(headers, signals) {
|
|
180
|
+
const result = responseCookies(headers);
|
|
181
|
+
if (!result.values.length)
|
|
182
|
+
return { state: result.state, count: 0, truncated: result.truncated };
|
|
183
|
+
let missingSecure = 0;
|
|
184
|
+
let missingHttpOnly = 0;
|
|
185
|
+
let missingSameSite = 0;
|
|
186
|
+
let sameSiteNoneWithoutSecure = 0;
|
|
187
|
+
for (const value of result.values) {
|
|
188
|
+
const segments = value.split(';');
|
|
189
|
+
if (!segments[0]?.includes('='))
|
|
190
|
+
return { state: 'malformed', count: result.values.length, truncated: result.truncated };
|
|
191
|
+
const attributes = new Map();
|
|
192
|
+
for (const rawAttribute of segments.slice(1, MAX_RESPONSE_POLICY_TOKENS + 1)) {
|
|
193
|
+
const [rawName = '', ...rawValue] = rawAttribute.trim().split('=');
|
|
194
|
+
const name = rawName.toLowerCase();
|
|
195
|
+
if (!name || !ATTRIBUTE_NAME_RE.test(name))
|
|
196
|
+
continue;
|
|
197
|
+
attributes.set(name, rawValue.length ? rawValue.join('=').trim().toLowerCase() : null);
|
|
198
|
+
}
|
|
199
|
+
const secure = attributes.has('secure');
|
|
200
|
+
const httpOnly = attributes.has('httponly');
|
|
201
|
+
const sameSite = attributes.get('samesite');
|
|
202
|
+
if (!secure)
|
|
203
|
+
missingSecure++;
|
|
204
|
+
if (!httpOnly)
|
|
205
|
+
missingHttpOnly++;
|
|
206
|
+
if (!sameSite)
|
|
207
|
+
missingSameSite++;
|
|
208
|
+
if (sameSite === 'none' && !secure)
|
|
209
|
+
sameSiteNoneWithoutSecure++;
|
|
210
|
+
}
|
|
211
|
+
if (missingSecure)
|
|
212
|
+
addSignal(signals, 'cookies_missing_secure', missingSecure);
|
|
213
|
+
if (missingHttpOnly)
|
|
214
|
+
addSignal(signals, 'cookies_missing_http_only', missingHttpOnly);
|
|
215
|
+
if (missingSameSite)
|
|
216
|
+
addSignal(signals, 'cookies_missing_same_site', missingSameSite);
|
|
217
|
+
if (sameSiteNoneWithoutSecure) {
|
|
218
|
+
addSignal(signals, 'cookies_same_site_none_without_secure', sameSiteNoneWithoutSecure);
|
|
219
|
+
}
|
|
220
|
+
return { state: result.state, count: result.values.length, truncated: result.truncated };
|
|
221
|
+
}
|
|
222
|
+
export function analyzeResponsePolicyHeaders(headers) {
|
|
223
|
+
const signals = [];
|
|
224
|
+
const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals);
|
|
225
|
+
const strictTransportSecurity = analyzeHsts(readHeader(headers, 'strict-transport-security'), signals);
|
|
226
|
+
const referrerPolicy = analyzeReferrerPolicy(readHeader(headers, 'referrer-policy'), signals);
|
|
227
|
+
const cookies = analyzeCookies(headers, signals);
|
|
228
|
+
const components = {
|
|
229
|
+
contentSecurityPolicy,
|
|
230
|
+
strictTransportSecurity,
|
|
231
|
+
referrerPolicy,
|
|
232
|
+
responseCookies: cookies.state,
|
|
233
|
+
};
|
|
234
|
+
const partial = Object.values(components).some((state) => state === 'partial' || state === 'malformed');
|
|
235
|
+
const limitations = [
|
|
236
|
+
'Policy findings describe only the selected HTTP response and are not a site-wide vulnerability or exploitability assessment.',
|
|
237
|
+
'Header and cookie values, names, paths, domains, nonces, hashes, and reporting endpoints are discarded after analysis.',
|
|
238
|
+
];
|
|
239
|
+
if (partial)
|
|
240
|
+
limitations.push('One or more selected policy values could not be interpreted completely.');
|
|
241
|
+
if (cookies.truncated)
|
|
242
|
+
limitations.push('Response-cookie analysis reached its configured count or byte limit.');
|
|
243
|
+
return {
|
|
244
|
+
responsePolicyVersion: RESPONSE_POLICY_VERSION,
|
|
245
|
+
status: partial ? 'partial' : 'success',
|
|
246
|
+
complete: !partial,
|
|
247
|
+
components,
|
|
248
|
+
signals: signals.slice(0, 16),
|
|
249
|
+
diagnostics: {
|
|
250
|
+
signalCount: Math.min(signals.length, 16),
|
|
251
|
+
cookieCount: boundedCount(cookies.count, MAX_RESPONSE_COOKIES),
|
|
252
|
+
cookiesTruncated: cookies.truncated,
|
|
253
|
+
},
|
|
254
|
+
limitations,
|
|
255
|
+
};
|
|
256
|
+
}
|