@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,423 @@
1
+ import { normalizeDomain } from "./case-model.js";
2
+ export const RELATIONSHIP_OBSERVATION_SCHEMA = 'whoisleuth.relationship-observations';
3
+ export const RELATIONSHIP_OBSERVATION_SCHEMA_VERSION = 1;
4
+ export const MAX_RELATIONSHIP_OBSERVATIONS = 300;
5
+ export const MAX_RELATIONSHIP_OBSERVATION_INPUTS = 1_200;
6
+ export const MAX_RELATIONSHIP_OBSERVATION_DOMAINS = 50;
7
+ export const MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS = 8;
8
+ export const MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH = 20_000;
9
+ export const MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES = 768 * 1024;
10
+ export const RELATIONSHIP_OBSERVATION_TYPES = Object.freeze([
11
+ 'nameserver_set',
12
+ 'ip_address',
13
+ 'certificate',
14
+ 'tracking_identifier',
15
+ 'favicon',
16
+ 'official_asset',
17
+ ]);
18
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
19
+ const SAFE_ID_RE = /^relationship-[a-z0-9]{1,16}-[a-z0-9]{1,16}$/u;
20
+ const SHA256_RE = /^[a-f0-9]{64}$/iu;
21
+ const DHASH_RE = /^[a-f0-9]{16}$/iu;
22
+ const TRACKING_IDENTIFIER_RE = /^[a-z-]{1,40}:[A-Z0-9-]{1,64}$/u;
23
+ const TYPES = new Set(RELATIONSHIP_OBSERVATION_TYPES);
24
+ const TYPE_METADATA = Object.freeze({
25
+ nameserver_set: Object.freeze({
26
+ label: 'Shared nameserver set',
27
+ method: 'Exact normalized set',
28
+ description: 'These domains reported the same normalized nameserver set. Shared DNS providers are common.',
29
+ }),
30
+ ip_address: Object.freeze({
31
+ label: 'Shared IP address',
32
+ method: 'Exact normalized address',
33
+ description: 'These domains resolved to the same IP address. Shared hosting, CDNs, and managed platforms are common.',
34
+ }),
35
+ certificate: Object.freeze({
36
+ label: 'Shared TLS certificate',
37
+ method: 'Exact leaf-certificate SHA-256',
38
+ description: 'These domains presented the same leaf certificate. Multi-domain certificates, shared hosting, CDNs, and managed platforms are common.',
39
+ }),
40
+ tracking_identifier: Object.freeze({
41
+ label: 'Shared tracking identifier',
42
+ method: 'Exact public identifier',
43
+ description: 'These pages exposed the same recognized public tracking identifier in bounded static HTML.',
44
+ }),
45
+ favicon: Object.freeze({
46
+ label: 'Similar favicon',
47
+ method: 'Exact SHA-256 or perceptual dHash distance ≤ 6',
48
+ description: 'These domains used an identical or perceptually similar favicon in the retained scan.',
49
+ }),
50
+ official_asset: Object.freeze({
51
+ label: 'Official asset relationship',
52
+ method: 'Configured-domain host match',
53
+ description: 'One or more pages loaded an asset from this configured official domain or its subdomain.',
54
+ }),
55
+ });
56
+ const FIXED_LIMITATIONS = Object.freeze([
57
+ 'This is analyst-selected derived evidence from bounded Bulk observations, not a complete infrastructure history.',
58
+ 'A shared observation does not prove ownership, coordination, intent, or maliciousness.',
59
+ ]);
60
+ function record(value) {
61
+ return value && typeof value === 'object' && !Array.isArray(value)
62
+ ? value
63
+ : null;
64
+ }
65
+ function text(value, maximum) {
66
+ if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
67
+ return '';
68
+ return value.replace(/\s+/gu, ' ').trim().slice(0, maximum).trim();
69
+ }
70
+ function timestamp(value) {
71
+ if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
72
+ return '';
73
+ const parsed = Date.parse(value);
74
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
75
+ }
76
+ function positiveInteger(value) {
77
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 1_000
78
+ ? value
79
+ : null;
80
+ }
81
+ function hashString(value, seed) {
82
+ let hash = seed >>> 0;
83
+ for (let index = 0; index < value.length; index += 1) {
84
+ hash ^= value.charCodeAt(index);
85
+ hash = Math.imul(hash, 16777619) >>> 0;
86
+ }
87
+ return hash.toString(36);
88
+ }
89
+ function observationId(canonical) {
90
+ return `relationship-${hashString(canonical, 2166136261)}-${hashString(canonical, 3339675911)}`;
91
+ }
92
+ function normalizedDomains(value) {
93
+ const output = new Set();
94
+ const input = Array.isArray(value) ? value : [];
95
+ for (const candidate of input.slice(0, MAX_RELATIONSHIP_OBSERVATION_DOMAINS * 4)) {
96
+ const domain = normalizeDomain(candidate);
97
+ if (domain)
98
+ output.add(domain);
99
+ if (output.size >= MAX_RELATIONSHIP_OBSERVATION_DOMAINS)
100
+ break;
101
+ }
102
+ return [...output].sort();
103
+ }
104
+ function normalizedLimitations(value) {
105
+ const output = new Set();
106
+ const input = Array.isArray(value) ? value : [];
107
+ for (const candidate of input.slice(0, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS * 4)) {
108
+ const limitation = text(candidate, 400);
109
+ if (limitation)
110
+ output.add(limitation);
111
+ if (output.size >= MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS)
112
+ break;
113
+ }
114
+ return [...output];
115
+ }
116
+ function ipv4(value) {
117
+ const parts = value.split('.');
118
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255))
119
+ return '';
120
+ return parts.map((part) => String(Number(part))).join('.');
121
+ }
122
+ function ipv6(value) {
123
+ if (!value.includes(':') || value.length > 45 || !/^[0-9a-f:.]+$/iu.test(value))
124
+ return '';
125
+ try {
126
+ const hostname = new URL(`http://[${value}]/`).hostname.toLowerCase();
127
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : '';
128
+ }
129
+ catch {
130
+ return '';
131
+ }
132
+ }
133
+ function normalizedNameserverSet(value) {
134
+ const candidates = value.split(/\s*·\s*/u);
135
+ if (!candidates.length || candidates.length > 80)
136
+ return '';
137
+ const nameservers = new Set();
138
+ for (const candidate of candidates) {
139
+ const hostname = normalizeDomain(candidate.replace(/\.$/u, ''));
140
+ if (!hostname)
141
+ return '';
142
+ nameservers.add(hostname);
143
+ if (nameservers.size > 20)
144
+ return '';
145
+ }
146
+ return [...nameservers].sort().join(' · ');
147
+ }
148
+ function normalizedFaviconEvidence(value, domains) {
149
+ const allowedDomains = new Set(domains);
150
+ const byDomain = new Map();
151
+ for (const entry of value.split('|').slice(0, MAX_RELATIONSHIP_OBSERVATION_DOMAINS * 4)) {
152
+ const separator = entry.indexOf('=');
153
+ if (separator < 1)
154
+ return '';
155
+ const domain = normalizeDomain(entry.slice(0, separator));
156
+ if (!domain || !allowedDomains.has(domain) || byDomain.has(domain))
157
+ return '';
158
+ const evidence = new Set();
159
+ for (const token of entry.slice(separator + 1).split(',').slice(0, 4)) {
160
+ const [kind, digest, ...extra] = token.split(':');
161
+ if (extra.length || !digest)
162
+ return '';
163
+ if (kind === 'sha256' && SHA256_RE.test(digest))
164
+ evidence.add(`sha256:${digest.toLowerCase()}`);
165
+ else if (kind === 'dhash' && DHASH_RE.test(digest))
166
+ evidence.add(`dhash:${digest.toLowerCase()}`);
167
+ else
168
+ return '';
169
+ }
170
+ if (!evidence.size)
171
+ return '';
172
+ byDomain.set(domain, [...evidence].sort());
173
+ }
174
+ if (byDomain.size !== allowedDomains.size)
175
+ return '';
176
+ return [...byDomain.entries()]
177
+ .sort(([left], [right]) => left.localeCompare(right))
178
+ .map(([domain, evidence]) => `${domain}=${evidence.join(',')}`)
179
+ .join('|');
180
+ }
181
+ function normalizedRelationshipValue(type, raw, domains) {
182
+ const value = text(raw, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH);
183
+ if (!value)
184
+ return '';
185
+ if (type === 'nameserver_set')
186
+ return normalizedNameserverSet(value);
187
+ if (type === 'ip_address')
188
+ return ipv4(value) || ipv6(value);
189
+ if (type === 'certificate')
190
+ return SHA256_RE.test(value) ? value.toLowerCase() : '';
191
+ if (type === 'tracking_identifier')
192
+ return TRACKING_IDENTIFIER_RE.test(value) ? value : '';
193
+ if (type === 'official_asset')
194
+ return normalizeDomain(value.replace(/\.$/u, ''));
195
+ return normalizedFaviconEvidence(value, domains);
196
+ }
197
+ function observationLimitations(value) {
198
+ return normalizedLimitations([...FIXED_LIMITATIONS, ...(Array.isArray(value) ? value : [])]);
199
+ }
200
+ function canonicalIdentity(type, normalizedValue, domains) {
201
+ return `${type}\u0000${normalizedValue}\u0000${domains.join('\u0000')}`;
202
+ }
203
+ export function relationshipObservationId(raw) {
204
+ const type = typeof raw.type === 'string' && TYPES.has(raw.type)
205
+ ? raw.type
206
+ : null;
207
+ const domains = normalizedDomains(raw.domains);
208
+ const normalizedValue = type
209
+ ? normalizedRelationshipValue(type, raw.normalizedValue ?? raw.value, domains)
210
+ : '';
211
+ return type && domains.length && normalizedValue
212
+ ? observationId(canonicalIdentity(type, normalizedValue, domains))
213
+ : '';
214
+ }
215
+ export function relationshipObservationStoreVersion(raw) {
216
+ const value = record(raw);
217
+ return positiveInteger(value?.version);
218
+ }
219
+ function observationList(raw) {
220
+ if (Array.isArray(raw))
221
+ return raw;
222
+ const value = record(raw);
223
+ return Array.isArray(value?.observations) ? value.observations : [];
224
+ }
225
+ export function normalizeRelationshipObservation(raw) {
226
+ const value = record(raw);
227
+ if (!value || typeof value.type !== 'string' || !TYPES.has(value.type))
228
+ return null;
229
+ const type = value.type;
230
+ const domains = normalizedDomains(value.domains);
231
+ const normalizedValue = normalizedRelationshipValue(type, value.normalizedValue, domains);
232
+ if (domains.length < 1 || !normalizedValue)
233
+ return null;
234
+ const metadata = TYPE_METADATA[type];
235
+ const canonical = canonicalIdentity(type, normalizedValue, domains);
236
+ // Imported identifiers are untrusted. Re-derive the identity from the
237
+ // normalized relationship value and members so an archive cannot alias one
238
+ // observation onto a different pivot.
239
+ const id = observationId(canonical);
240
+ const observedAt = timestamp(value.observedAt);
241
+ const retainedAt = timestamp(value.retainedAt);
242
+ if (!observedAt || !retainedAt)
243
+ return null;
244
+ return {
245
+ id,
246
+ type,
247
+ label: metadata.label,
248
+ method: metadata.method,
249
+ normalizedValue,
250
+ displayValue: type === 'favicon' ? '' : normalizedValue,
251
+ domains,
252
+ description: metadata.description,
253
+ classification: 'derived',
254
+ source: 'bulk_relationship_analysis',
255
+ sourceVersion: positiveInteger(value.sourceVersion) || 1,
256
+ observedAt,
257
+ retainedAt,
258
+ complete: value.complete === true,
259
+ truncated: value.truncated === true
260
+ || (Array.isArray(value.domains) && value.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
261
+ limitations: observationLimitations(value.limitations),
262
+ };
263
+ }
264
+ export function normalizeRelationshipObservationStore(raw) {
265
+ const byId = new Map();
266
+ for (const candidate of observationList(raw).slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
267
+ const observation = normalizeRelationshipObservation(candidate);
268
+ if (!observation)
269
+ continue;
270
+ const existing = byId.get(observation.id);
271
+ if (!existing || observation.retainedAt > existing.retainedAt)
272
+ byId.set(observation.id, observation);
273
+ }
274
+ return {
275
+ version: RELATIONSHIP_OBSERVATION_SCHEMA_VERSION,
276
+ observations: [...byId.values()]
277
+ .sort((left, right) => right.retainedAt.localeCompare(left.retainedAt) || left.id.localeCompare(right.id))
278
+ .slice(0, MAX_RELATIONSHIP_OBSERVATIONS),
279
+ };
280
+ }
281
+ export function createRelationshipObservation(raw, options = {}) {
282
+ const type = typeof raw.type === 'string' && TYPES.has(raw.type)
283
+ ? raw.type
284
+ : null;
285
+ const domains = normalizedDomains(raw.domains);
286
+ const normalizedValue = type
287
+ ? normalizedRelationshipValue(type, raw.normalizedValue ?? raw.value, domains)
288
+ : '';
289
+ if (!type || domains.length < 1 || !normalizedValue) {
290
+ throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
291
+ }
292
+ const now = new Date().toISOString();
293
+ const observedAt = timestamp(options.observedAt) || now;
294
+ const retainedAt = timestamp(options.retainedAt) || now;
295
+ const canonical = canonicalIdentity(type, normalizedValue, domains);
296
+ const metadata = TYPE_METADATA[type];
297
+ return {
298
+ id: observationId(canonical),
299
+ type,
300
+ label: metadata.label,
301
+ method: metadata.method,
302
+ normalizedValue,
303
+ displayValue: type === 'favicon' ? '' : normalizedValue,
304
+ domains,
305
+ description: metadata.description,
306
+ classification: 'derived',
307
+ source: 'bulk_relationship_analysis',
308
+ sourceVersion: positiveInteger(options.sourceVersion) || 1,
309
+ observedAt,
310
+ retainedAt,
311
+ complete: options.complete === true,
312
+ truncated: options.truncated === true
313
+ || (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
314
+ limitations: observationLimitations(options.limitations),
315
+ };
316
+ }
317
+ function byteLength(value) {
318
+ return new TextEncoder().encode(value).byteLength;
319
+ }
320
+ export function serializeRelationshipObservationStore(raw) {
321
+ const normalized = normalizeRelationshipObservationStore(raw);
322
+ const serialized = JSON.stringify({
323
+ schema: RELATIONSHIP_OBSERVATION_SCHEMA,
324
+ version: RELATIONSHIP_OBSERVATION_SCHEMA_VERSION,
325
+ observations: normalized.observations,
326
+ });
327
+ if (byteLength(serialized) > MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES) {
328
+ throw new Error('Retained relationship observations exceed the 768 KiB browser-storage limit. Delete or export older observations first.');
329
+ }
330
+ return serialized;
331
+ }
332
+ export function upsertRelationshipObservation(rawStore, observationRaw) {
333
+ const observation = normalizeRelationshipObservation(observationRaw);
334
+ if (!observation)
335
+ throw new Error('That retained relationship observation is malformed.');
336
+ const current = normalizeRelationshipObservationStore(rawStore).observations;
337
+ const added = !current.some((item) => item.id === observation.id);
338
+ const merged = [observation, ...current.filter((item) => item.id !== observation.id)];
339
+ const normalized = normalizeRelationshipObservationStore(merged).observations;
340
+ let pruned = Math.max(0, merged.length - normalized.length);
341
+ let bounded = normalized;
342
+ while (bounded.length) {
343
+ try {
344
+ serializeRelationshipObservationStore(bounded);
345
+ break;
346
+ }
347
+ catch {
348
+ bounded = bounded.slice(0, -1);
349
+ pruned += 1;
350
+ }
351
+ }
352
+ if (!bounded.some((item) => item.id === observation.id)) {
353
+ throw new Error('That relationship observation is too large for the bounded browser store.');
354
+ }
355
+ return { observations: bounded, record: observation, added, pruned };
356
+ }
357
+ export function deleteRelationshipObservation(rawStore, id) {
358
+ const safeId = typeof id === 'string' && SAFE_ID_RE.test(id) ? id : '';
359
+ if (!safeId)
360
+ throw new Error('That retained relationship observation is invalid.');
361
+ return normalizeRelationshipObservationStore(rawStore).observations.filter((item) => item.id !== safeId);
362
+ }
363
+ export function mergeRelationshipObservations(localRaw, importedRaw) {
364
+ const importedRecord = record(importedRaw);
365
+ if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
366
+ throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
367
+ }
368
+ const importedVersion = relationshipObservationStoreVersion(importedRaw);
369
+ if (importedVersion !== null && importedVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
370
+ throw new Error(`This relationship-observation section uses newer schema ${importedVersion}. Update the app before importing it.`);
371
+ }
372
+ const local = normalizeRelationshipObservationStore(localRaw).observations;
373
+ const importedInput = observationList(importedRaw);
374
+ const byId = new Map(local.map((item) => [item.id, item]));
375
+ let added = 0;
376
+ let updated = 0;
377
+ let skipped = Math.max(0, importedInput.length - MAX_RELATIONSHIP_OBSERVATION_INPUTS);
378
+ for (const candidate of importedInput.slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
379
+ const observation = normalizeRelationshipObservation(candidate);
380
+ if (!observation) {
381
+ skipped += 1;
382
+ continue;
383
+ }
384
+ const existing = byId.get(observation.id);
385
+ if (existing && existing.retainedAt >= observation.retainedAt) {
386
+ skipped += 1;
387
+ continue;
388
+ }
389
+ if (existing)
390
+ updated += 1;
391
+ else
392
+ added += 1;
393
+ byId.set(observation.id, observation);
394
+ }
395
+ const mergedInput = [...byId.values()];
396
+ const normalized = normalizeRelationshipObservationStore(mergedInput).observations;
397
+ let pruned = Math.max(0, mergedInput.length - normalized.length);
398
+ let bounded = normalized;
399
+ while (bounded.length) {
400
+ try {
401
+ serializeRelationshipObservationStore(bounded);
402
+ break;
403
+ }
404
+ catch {
405
+ bounded = bounded.slice(0, -1);
406
+ pruned += 1;
407
+ }
408
+ }
409
+ return { observations: bounded, added, updated, skipped, pruned };
410
+ }
411
+ export function buildRelationshipObservationExport(raw, generatedAt = new Date().toISOString()) {
412
+ const normalized = normalizeRelationshipObservationStore(raw);
413
+ return {
414
+ schema: RELATIONSHIP_OBSERVATION_SCHEMA,
415
+ version: RELATIONSHIP_OBSERVATION_SCHEMA_VERSION,
416
+ generatedAt: timestamp(generatedAt) || new Date().toISOString(),
417
+ observations: normalized.observations,
418
+ limitations: [
419
+ 'These are analyst-selected derived pivots, not proof of ownership, coordination, intent, or maliciousness.',
420
+ 'The export excludes the originating raw lookup responses and complete Bulk scan.',
421
+ ],
422
+ };
423
+ }
@@ -0,0 +1,135 @@
1
+ export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
2
+ function plainRecord(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ }
7
+ export function fmtAge(value) {
8
+ if (typeof value !== 'number' || !Number.isFinite(value))
9
+ return null;
10
+ const days = value;
11
+ if (days < 60)
12
+ return `${days}d old`;
13
+ const years = days / 365.25;
14
+ return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
15
+ }
16
+ export function fmtExpiresIn(value) {
17
+ if (typeof value !== 'number' || !Number.isFinite(value))
18
+ return null;
19
+ const days = value;
20
+ return days < 0 ? `expired ${Math.abs(days)}d ago` : `expires in ${days}d`;
21
+ }
22
+ export const ACTIVITY_LABELS = {
23
+ active: 'Active site',
24
+ parked: 'Parked / for-sale page',
25
+ unreachable: 'Website check inconclusive',
26
+ // Kept only so older saved watchlist snapshots remain readable. New scans
27
+ // use `unreachable`, because a fetch failure never proves that no site exists.
28
+ no_site: 'No site reported (legacy)',
29
+ };
30
+ export function formatPrivacyCell(v) {
31
+ if (v === true)
32
+ return 'Privacy protected';
33
+ if (v === false)
34
+ return 'Public registrant data';
35
+ return '—';
36
+ }
37
+ export function formatActivityCell(v, hasMx, hasSpf, hasDmarc) {
38
+ const label = typeof v === 'string' && v in ACTIVITY_LABELS
39
+ ? ACTIVITY_LABELS[v]
40
+ : '—';
41
+ const mailParts = [];
42
+ if (hasMx)
43
+ mailParts.push('MX');
44
+ if (hasSpf)
45
+ mailParts.push('SPF');
46
+ if (hasDmarc)
47
+ mailParts.push('DMARC');
48
+ return mailParts.length ? `${label} · ${mailParts.join('+')}` : label;
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Opportunity score: combines the signals above into one 0-100 number so a
52
+ // large fast-scan result set can be sorted instead of manually scanned.
53
+ // Higher = more actionable (cheap/easy to get), not "more valuable" in an
54
+ // appraisal sense - a parked, aged, publicly-contactable domain scores high
55
+ // because it's an easy approach; an actively-used one scores low because
56
+ // the owner is unlikely to be interested regardless of the domain's worth.
57
+ // ---------------------------------------------------------------------------
58
+ const STATE_BASE_SCORE = {
59
+ for_sale: 95,
60
+ expiring: 85,
61
+ available: 90,
62
+ registered: 40,
63
+ unknown: null,
64
+ error: null,
65
+ };
66
+ const STATE_LABELS = {
67
+ for_sale: 'for sale',
68
+ expiring: 'expiring/pending delete',
69
+ available: 'available',
70
+ registered: 'registered',
71
+ };
72
+ // Builds the opportunity score alongside a factor-by-factor breakdown (base
73
+ // state score, then each signal's contribution) - the single source of
74
+ // truth computeOpportunityScore() below reads its final number from, and
75
+ // what the score chips' tooltips and the CSV export both render from, so
76
+ // the displayed reasoning can never drift from the actual number.
77
+ export function explainOpportunityScore(value) {
78
+ const r = plainRecord(value);
79
+ if (!r)
80
+ return null;
81
+ const rawState = r.availability ?? r.state;
82
+ if (typeof rawState !== 'string')
83
+ return null;
84
+ const state = rawState;
85
+ const base = STATE_BASE_SCORE[state];
86
+ if (base === null || base === undefined)
87
+ return null;
88
+ const factors = [{ label: `Base score for "${STATE_LABELS[state] || state}"`, delta: base }];
89
+ let score = base;
90
+ if (r.activityStatus === 'parked') {
91
+ factors.push({ label: 'Parked/for-sale page', delta: 15 });
92
+ score += 15;
93
+ }
94
+ else if (r.activityStatus === 'no_site') {
95
+ factors.push({ label: 'No site running', delta: 5 });
96
+ score += 5;
97
+ }
98
+ else if (r.activityStatus === 'active') {
99
+ factors.push({ label: 'Active site in use', delta: -20 });
100
+ score -= 20;
101
+ }
102
+ if (r.privacyProtected === false) {
103
+ factors.push({ label: 'Contact info public', delta: 10 });
104
+ score += 10;
105
+ }
106
+ else if (r.privacyProtected === true) {
107
+ factors.push({ label: 'WHOIS privacy protected', delta: -10 });
108
+ score -= 10;
109
+ }
110
+ if (typeof r.domainAgeDays === 'number') {
111
+ const ageBonus = Math.min(20, (r.domainAgeDays / 365) * 2);
112
+ if (ageBonus !== 0) {
113
+ factors.push({ label: `Domain age (${fmtAge(r.domainAgeDays)})`, delta: ageBonus });
114
+ score += ageBonus;
115
+ }
116
+ }
117
+ if (state === 'registered' && typeof r.expiresInDays === 'number' && r.expiresInDays >= 0 && r.expiresInDays < 30) {
118
+ factors.push({ label: 'Expires within 30 days', delta: 10 }); // might lapse soon even though not yet flagged "expiring"
119
+ score += 10;
120
+ }
121
+ return { score: Math.max(0, Math.min(100, Math.round(score))), factors };
122
+ }
123
+ export function computeOpportunityScore(value) {
124
+ const explained = explainOpportunityScore(value);
125
+ return explained ? explained.score : null;
126
+ }
127
+ export function scoreTone(score) {
128
+ if (score === null)
129
+ return 'neutral';
130
+ if (score >= 70)
131
+ return 'good';
132
+ if (score >= 40)
133
+ return 'neutral';
134
+ return 'warn';
135
+ }