@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.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,265 @@
1
+ // Bounded, scan-local relationship evidence for Bulk. Shared
2
+ // observations can help an analyst pivot between domains, but they are not
3
+ // proof of common ownership, coordination, intent, or maliciousness. This
4
+ // module deliberately produces no aggregate score and is never a persistence
5
+ // boundary.
6
+ import { normalizeDomain } from "./case-model.js";
7
+ import { groupBySimilarFavicon } from "./utils.js";
8
+ export const RELATIONSHIP_EVIDENCE_VERSION = 2;
9
+ export const TLS_RELATIONSHIP_PROFILE_VERSION = 2;
10
+ export const SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS = Object.freeze([1, TLS_RELATIONSHIP_PROFILE_VERSION]);
11
+ export const MAX_RELATIONSHIP_ROWS = 2000;
12
+ export const MAX_RELATIONSHIP_GROUPS = 100;
13
+ export const MAX_RELATIONSHIP_DOMAINS = 50;
14
+ export const MAX_NAMESERVERS_PER_ROW = 20;
15
+ export const MAX_IPS_PER_ROW = 50;
16
+ export const MAX_TRACKING_IDS_PER_ROW = 30;
17
+ export const MAX_OFFICIAL_ASSET_HOSTS_PER_ROW = 30;
18
+ export const MAX_OFFICIAL_DOMAINS = 200;
19
+ export const MAX_FAVICON_ROWS = 250;
20
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
21
+ const FAVICON_SHA_RE = /^[a-f0-9]{64}$/i;
22
+ const FAVICON_PHASH_RE = /^[a-f0-9]{16}$/i;
23
+ const CERTIFICATE_SHA_RE = /^[a-f0-9]{64}$/i;
24
+ function record(value) {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ function hostname(value) {
30
+ if (typeof value !== 'string' || value.length > 253 || CONTROL_RE.test(value))
31
+ return '';
32
+ return normalizeDomain(value.replace(/\.$/, ''));
33
+ }
34
+ function ipv4(value) {
35
+ if (typeof value !== 'string' || value.length > 15 || CONTROL_RE.test(value))
36
+ return '';
37
+ const parts = value.split('.');
38
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part) || Number(part) > 255))
39
+ return '';
40
+ return parts.map((part) => String(Number(part))).join('.');
41
+ }
42
+ function ipv6(value) {
43
+ if (typeof value !== 'string' || value.length > 45 || CONTROL_RE.test(value)
44
+ || !value.includes(':') || !/^[0-9a-f:.]+$/i.test(value))
45
+ return '';
46
+ try {
47
+ const normalized = new URL(`http://[${value}]/`).hostname.toLowerCase();
48
+ return normalized.startsWith('[') && normalized.endsWith(']') ? normalized.slice(1, -1) : '';
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ function ipAddress(value) {
55
+ return ipv4(value) || ipv6(value);
56
+ }
57
+ function boundedSet(values, normalize, limit) {
58
+ const output = new Set();
59
+ let truncated = values.length > limit * 4;
60
+ for (const candidate of values.slice(0, limit * 4)) {
61
+ const normalized = normalize(candidate);
62
+ if (!normalized || output.has(normalized))
63
+ continue;
64
+ if (output.size >= limit)
65
+ truncated = true;
66
+ else
67
+ output.add(normalized);
68
+ }
69
+ return { values: [...output].sort(), truncated };
70
+ }
71
+ function identifier(value) {
72
+ const item = record(value);
73
+ if (!item || typeof item.type !== 'string' || typeof item.value !== 'string')
74
+ return '';
75
+ if (!/^[a-z-]{1,40}$/.test(item.type) || !/^[A-Z0-9-]{1,64}$/.test(item.value))
76
+ return '';
77
+ return `${item.type}:${item.value}`;
78
+ }
79
+ function trackingCandidates(availability) {
80
+ const identity = record(availability.pageIdentity);
81
+ const fingerprints = record(identity?.fingerprints);
82
+ const identifiers = record(fingerprints?.identifiers);
83
+ if (Array.isArray(identifiers?.values))
84
+ return identifiers.values;
85
+ return Array.isArray(identity?.trackingIdentifiers) ? identity.trackingIdentifiers : [];
86
+ }
87
+ function dnsRecords(availability) {
88
+ const dns = record(availability.dns);
89
+ return record(dns?.records) || {};
90
+ }
91
+ function tlsCertificateFingerprint(availability) {
92
+ const tls = record(availability.tls);
93
+ const certificate = record(tls?.certificate);
94
+ const fingerprint = certificate?.fingerprintSha256;
95
+ const status = tls?.status;
96
+ if (tls?.source !== 'tls' || !SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS.includes(Number(tls?.profileVersion))
97
+ || typeof status !== 'string' || !['success', 'partial'].includes(status) || typeof fingerprint !== 'string'
98
+ || fingerprint.length !== 64 || !CERTIFICATE_SHA_RE.test(fingerprint))
99
+ return null;
100
+ return fingerprint.toLowerCase();
101
+ }
102
+ /**
103
+ * Normalizes only the observations used by the current Bulk result set. The
104
+ * returned object is transient and contains no raw HTML, response bodies,
105
+ * complete DNS payload, URLs, certificate bodies, or Certificate Transparency
106
+ * rows. The TLS field is an exact normalized leaf-certificate digest only.
107
+ * @param {unknown} rawAvailability
108
+ * @param {unknown} rawOfficialDomains
109
+ */
110
+ export function relationshipObservation(rawAvailability, rawOfficialDomains = []) {
111
+ const availability = record(rawAvailability) || {};
112
+ const records = dnsRecords(availability);
113
+ const nameserverSource = [
114
+ ...(Array.isArray(availability.nameservers) ? availability.nameservers : []),
115
+ ...(Array.isArray(records.ns) ? records.ns : []),
116
+ ];
117
+ const addressSource = [
118
+ ...(Array.isArray(records.a) ? records.a : []),
119
+ ...(Array.isArray(records.aaaa) ? records.aaaa : []),
120
+ ];
121
+ const nameservers = boundedSet(nameserverSource, hostname, MAX_NAMESERVERS_PER_ROW);
122
+ const ipAddresses = boundedSet(addressSource, ipAddress, MAX_IPS_PER_ROW);
123
+ const trackingIdentifiers = boundedSet(trackingCandidates(availability), identifier, MAX_TRACKING_IDS_PER_ROW);
124
+ const officialDomainSource = Array.isArray(rawOfficialDomains) ? rawOfficialDomains : [];
125
+ const officialDomains = new Set(officialDomainSource
126
+ .slice(0, MAX_OFFICIAL_DOMAINS)
127
+ .map(normalizeDomain)
128
+ .filter((domain) => Boolean(domain)));
129
+ const assets = boundedSet(Array.isArray(availability.externalAssetHosts) ? availability.externalAssetHosts : [], hostname, MAX_OFFICIAL_ASSET_HOSTS_PER_ROW);
130
+ const officialAssetHosts = assets.values.filter((host) => [...officialDomains].some((domain) => host === domain || host.endsWith(`.${domain}`)));
131
+ const faviconHash = typeof availability.faviconHash === 'string' && FAVICON_SHA_RE.test(availability.faviconHash)
132
+ ? availability.faviconHash.toLowerCase() : null;
133
+ const faviconPHash = typeof availability.faviconPHash === 'string' && FAVICON_PHASH_RE.test(availability.faviconPHash)
134
+ ? availability.faviconPHash.toLowerCase() : null;
135
+ return {
136
+ version: RELATIONSHIP_EVIDENCE_VERSION,
137
+ nameservers: nameservers.values,
138
+ ipAddresses: ipAddresses.values,
139
+ trackingIdentifiers: trackingIdentifiers.values,
140
+ officialAssetHosts,
141
+ faviconHash,
142
+ faviconPHash,
143
+ certificateFingerprint: tlsCertificateFingerprint(availability),
144
+ truncated: nameservers.truncated || ipAddresses.truncated || trackingIdentifiers.truncated || assets.truncated
145
+ || officialDomainSource.length > MAX_OFFICIAL_DOMAINS,
146
+ };
147
+ }
148
+ function addBucket(buckets, value, domain) {
149
+ if (!value)
150
+ return;
151
+ if (!buckets.has(value))
152
+ buckets.set(value, new Set());
153
+ buckets.get(value)?.add(domain);
154
+ }
155
+ function group(type, label, method, value, domains, description, normalizedValue = value) {
156
+ return { type, label, method, value, normalizedValue, domains, description };
157
+ }
158
+ /**
159
+ * @param {Array<{domain?:unknown,trusted?:unknown,relationship?:unknown}>} rawRows
160
+ */
161
+ export function buildScanRelationships(rawRows) {
162
+ const input = Array.isArray(rawRows) ? rawRows : [];
163
+ let truncated = input.length > MAX_RELATIONSHIP_ROWS;
164
+ const rows = [];
165
+ for (const raw of input.slice(0, MAX_RELATIONSHIP_ROWS)) {
166
+ const domain = normalizeDomain(raw?.domain);
167
+ const observation = record(raw?.relationship);
168
+ if (!domain || raw?.trusted || !observation || observation.version !== RELATIONSHIP_EVIDENCE_VERSION)
169
+ continue;
170
+ rows.push({ domain, observation });
171
+ if (observation.truncated === true)
172
+ truncated = true;
173
+ }
174
+ const nameserverSets = new Map();
175
+ const addresses = new Map();
176
+ const identifiers = new Map();
177
+ const certificates = new Map();
178
+ const officialAssets = new Map();
179
+ for (const { domain, observation } of rows) {
180
+ const nameserverSource = Array.isArray(observation.nameservers) ? observation.nameservers : [];
181
+ const addressSource = Array.isArray(observation.ipAddresses) ? observation.ipAddresses : [];
182
+ const identifierSource = Array.isArray(observation.trackingIdentifiers) ? observation.trackingIdentifiers : [];
183
+ const assetSource = Array.isArray(observation.officialAssetHosts) ? observation.officialAssetHosts : [];
184
+ if (nameserverSource.length > MAX_NAMESERVERS_PER_ROW || addressSource.length > MAX_IPS_PER_ROW
185
+ || identifierSource.length > MAX_TRACKING_IDS_PER_ROW || assetSource.length > MAX_OFFICIAL_ASSET_HOSTS_PER_ROW)
186
+ truncated = true;
187
+ const nameservers = nameserverSource.slice(0, MAX_NAMESERVERS_PER_ROW).map(hostname).filter(Boolean);
188
+ if (nameservers.length)
189
+ addBucket(nameserverSets, [...new Set(nameservers)].sort().join(' · '), domain);
190
+ for (const value of addressSource.slice(0, MAX_IPS_PER_ROW))
191
+ addBucket(addresses, ipAddress(value), domain);
192
+ for (const value of identifierSource.slice(0, MAX_TRACKING_IDS_PER_ROW)) {
193
+ if (typeof value === 'string' && /^[a-z-]{1,40}:[A-Z0-9-]{1,64}$/.test(value)) {
194
+ addBucket(identifiers, value, domain);
195
+ }
196
+ }
197
+ const certificateFingerprint = typeof observation.certificateFingerprint === 'string'
198
+ && observation.certificateFingerprint.length === 64
199
+ && CERTIFICATE_SHA_RE.test(observation.certificateFingerprint)
200
+ ? observation.certificateFingerprint.toLowerCase()
201
+ : '';
202
+ addBucket(certificates, certificateFingerprint, domain);
203
+ for (const value of assetSource.slice(0, MAX_OFFICIAL_ASSET_HOSTS_PER_ROW))
204
+ addBucket(officialAssets, hostname(value), domain);
205
+ }
206
+ const output = [];
207
+ for (const [value, domains] of nameserverSets)
208
+ if (domains.size >= 2)
209
+ output.push(group('nameserver_set', 'Shared nameserver set', 'Exact normalized set', value, [...domains].sort(), 'These domains reported the same normalized nameserver set retained by this scan.'));
210
+ for (const [value, domains] of addresses)
211
+ if (domains.size >= 2)
212
+ output.push(group('ip_address', 'Shared IP address', 'Exact normalized address', value, [...domains].sort(), 'These domains resolved to the same IP address in this scan. Shared hosting and CDNs are common.'));
213
+ for (const [value, domains] of certificates)
214
+ if (domains.size >= 2)
215
+ output.push(group('certificate', 'Shared TLS certificate', 'Exact leaf-certificate SHA-256', value, [...domains].sort(), 'These domains presented the same leaf certificate in this scan. Multi-domain certificates, shared hosting, CDNs, and managed platforms are common.'));
216
+ for (const [value, domains] of identifiers)
217
+ if (domains.size >= 2)
218
+ output.push(group('tracking_identifier', 'Shared tracking identifier', 'Exact public identifier', value, [...domains].sort(), 'These pages exposed the same recognized public tracking identifier in bounded static HTML.'));
219
+ const faviconRows = rows.slice(0, MAX_FAVICON_ROWS).map(({ domain, observation }) => ({
220
+ domain,
221
+ faviconHash: typeof observation.faviconHash === 'string' && FAVICON_SHA_RE.test(observation.faviconHash)
222
+ ? observation.faviconHash.toLowerCase() : null,
223
+ faviconPHash: typeof observation.faviconPHash === 'string' && FAVICON_PHASH_RE.test(observation.faviconPHash)
224
+ ? observation.faviconPHash.toLowerCase() : null,
225
+ }));
226
+ if (rows.length > MAX_FAVICON_ROWS)
227
+ truncated = true;
228
+ for (const domains of groupBySimilarFavicon(faviconRows, 6)) {
229
+ const distinctDomains = [...new Set(domains)].sort();
230
+ if (distinctDomains.length >= 2) {
231
+ const evidenceByDomain = new Map(faviconRows.map((row) => [row.domain, row]));
232
+ const normalizedValue = distinctDomains.map((domain) => {
233
+ const evidence = evidenceByDomain.get(domain);
234
+ return `${domain}=${[
235
+ evidence?.faviconHash ? `sha256:${evidence.faviconHash}` : '',
236
+ evidence?.faviconPHash ? `dhash:${evidence.faviconPHash}` : '',
237
+ ].filter(Boolean).join(',')}`;
238
+ }).join('|');
239
+ output.push(group('favicon', 'Similar favicon', 'Exact SHA-256 or perceptual dHash distance ≤ 6', '', distinctDomains, 'These domains used an identical or perceptually similar favicon in this scan.', normalizedValue));
240
+ }
241
+ }
242
+ for (const [value, domains] of officialAssets)
243
+ output.push(group('official_asset', 'Official asset relationship', 'Configured-domain host match', value, [...domains].sort(), 'One or more pages loaded an asset from this configured official domain or its subdomain.'));
244
+ const order = new Map(['nameserver_set', 'ip_address', 'certificate', 'tracking_identifier', 'favicon', 'official_asset']
245
+ .map((value, index) => [value, index]));
246
+ output.sort((left, right) => (Number(order.get(left.type)) - Number(order.get(right.type))) || left.value.localeCompare(right.value) || left.domains.join('|').localeCompare(right.domains.join('|')));
247
+ if (output.length > MAX_RELATIONSHIP_GROUPS)
248
+ truncated = true;
249
+ const groups = output.slice(0, MAX_RELATIONSHIP_GROUPS).map((item) => {
250
+ if (item.domains.length <= MAX_RELATIONSHIP_DOMAINS)
251
+ return item;
252
+ truncated = true;
253
+ return { ...item, domains: item.domains.slice(0, MAX_RELATIONSHIP_DOMAINS) };
254
+ });
255
+ return {
256
+ version: RELATIONSHIP_EVIDENCE_VERSION,
257
+ groups,
258
+ truncated,
259
+ limitations: [
260
+ 'Shared observations are investigation pivots, not proof of common ownership, coordination, intent, or maliciousness.',
261
+ 'Certificate relationships use exact native TLS leaf-certificate SHA-256 values only. Certificate Transparency counts and hostnames are never treated as certificate reuse.',
262
+ 'A shared certificate can reflect a multi-domain certificate, shared hosting, CDN, or managed platform and does not establish common control.',
263
+ ],
264
+ };
265
+ }