@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,248 @@
1
+ import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
2
+ import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
3
+ const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
4
+ const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
5
+ const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
6
+ function assignUniqueIds(cases) {
7
+ const used = new Set();
8
+ for (const record of [...cases].sort((a, b) => a.domain.localeCompare(b.domain))) {
9
+ let id = safeId(record.id) || deterministicId(record.domain);
10
+ if (used.has(id)) {
11
+ const base = deterministicId(record.domain);
12
+ id = base;
13
+ let suffix = 2;
14
+ while (used.has(id))
15
+ id = `${base}-${suffix++}`;
16
+ }
17
+ used.add(id);
18
+ record.id = id;
19
+ }
20
+ }
21
+ /**
22
+ * Recovers a clean, bounded store from an arbitrary parsed value. Accepts the
23
+ * versioned envelope or a bare array, drops malformed records, keeps a single
24
+ * case per domain (most recently updated wins), caps to MAX_CASES by recency,
25
+ * and guarantees globally unique safe ids. Never throws.
26
+ * @param {unknown} raw
27
+ * @returns {CaseStore}
28
+ */
29
+ export function normalizeCaseStore(raw) {
30
+ const now = new Date().toISOString();
31
+ const byDomain = new Map();
32
+ for (const item of asCaseList(raw)) {
33
+ const normalized = normalizeCase(item, undefined, now);
34
+ if (!normalized)
35
+ continue;
36
+ const existing = byDomain.get(normalized.domain);
37
+ if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
38
+ byDomain.set(normalized.domain, normalized);
39
+ }
40
+ }
41
+ const cases = [...byDomain.values()]
42
+ .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
43
+ .slice(0, MAX_CASES);
44
+ assignUniqueIds(cases);
45
+ return { version: CASE_SCHEMA_VERSION, cases };
46
+ }
47
+ /**
48
+ * The schema version declared by a stored/parsed value, or null. The storage
49
+ * wrapper uses this to refuse overwriting data written by a newer, unsupported
50
+ * version instead of silently downgrading it.
51
+ * @param {unknown} raw
52
+ * @returns {number | null}
53
+ */
54
+ export function parseStoreVersion(raw) {
55
+ const value = objectRecord(raw).version;
56
+ return typeof value === 'number' ? value : null;
57
+ }
58
+ /** @param {unknown} raw @returns {unknown[]} */
59
+ function asCaseList(raw) {
60
+ if (Array.isArray(raw))
61
+ return raw;
62
+ const cases = objectRecord(raw).cases;
63
+ if (Array.isArray(cases))
64
+ return cases;
65
+ return [];
66
+ }
67
+ /**
68
+ * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
69
+ * @param {string} [nowIso]
70
+ * @returns {CaseRecord}
71
+ */
72
+ function importScalar(value, valid) {
73
+ return typeof value === 'string' && valid.has(value) ? value : undefined;
74
+ }
75
+ /**
76
+ * Validates one imported record into a patch. Unlike normalizeCase, absent or
77
+ * invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
78
+ * updatedAt stays `null` (treated as older than any real local timestamp), so an
79
+ * incomplete import can never win a merge over valid local data. Imported
80
+ * evidence is normalized additively; a snapshot with no captured time falls back
81
+ * only to the imported record's own (older) timestamps, never to "now".
82
+ * @param {unknown} raw
83
+ * @returns {ImportPatch | null}
84
+ */
85
+ function extractImportPatch(raw) {
86
+ const record = objectRecord(raw);
87
+ const domain = normalizeDomain(record.domain);
88
+ if (!domain)
89
+ return null;
90
+ const importFallback = isoOrNull(record.updatedAt) || isoOrNull(record.createdAt) || null;
91
+ const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
92
+ const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
93
+ const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback);
94
+ const pinIds = new Set(evidencePins.map((item) => item.id));
95
+ return {
96
+ domain,
97
+ rawId: typeof record.id === 'string' ? record.id : null,
98
+ status: importScalar(record.status, STATUS_VALUES),
99
+ disposition: importScalar(record.disposition, DISPOSITION_VALUES),
100
+ source: importScalar(record.source, SOURCE_VALUES),
101
+ evidenceHistory: normalizeEvidenceHistory(rawEvidence, { source: 'import', fallback: importFallback }),
102
+ evidencePins,
103
+ decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds),
104
+ actions: normalizeCaseActions(record.actions, normalizedFallback),
105
+ assertions: normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds),
106
+ manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback),
107
+ sightings: normalizeCaseSightings(record.sightings, normalizedFallback, pinIds),
108
+ tags: normalizeTags(record.tags),
109
+ // Imported notes fall back only to the imported record's own timestamps
110
+ // (never "now"), so a timestamp-less note gets a stable, deterministic time
111
+ // and id, and re-importing the same file cannot manufacture a duplicate or a
112
+ // spuriously-newer note.
113
+ notes: normalizeNotes(record.notes, importFallback),
114
+ createdAt: isoOrNull(record.createdAt),
115
+ updatedAt: isoOrNull(record.updatedAt),
116
+ };
117
+ }
118
+ /** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
119
+ function unionNotes(a, b) {
120
+ const byId = new Map();
121
+ for (const note of [...a, ...b]) {
122
+ if (!byId.has(note.id))
123
+ byId.set(note.id, note);
124
+ }
125
+ const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
126
+ return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
127
+ }
128
+ // Additive, deduplicated union of two evidence histories. Identical material
129
+ // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
130
+ // are retained subject to the per-case bound, and an older import can never
131
+ // move an existing observation backwards.
132
+ function mergeEvidenceHistories(local, imported) {
133
+ return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null });
134
+ }
135
+ /** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
136
+ function caseFromPatch(patch, now) {
137
+ return {
138
+ id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
139
+ domain: patch.domain,
140
+ status: patch.status ?? DEFAULT_STATUS,
141
+ disposition: patch.disposition ?? DEFAULT_DISPOSITION,
142
+ tags: patch.tags,
143
+ notes: patch.notes,
144
+ source: patch.source ?? DEFAULT_SOURCE,
145
+ evidenceHistory: patch.evidenceHistory,
146
+ evidencePins: patch.evidencePins,
147
+ decisions: patch.decisions,
148
+ actions: patch.actions,
149
+ assertions: patch.assertions,
150
+ manualTrail: patch.manualTrail,
151
+ sightings: patch.sightings,
152
+ createdAt: patch.createdAt || patch.updatedAt || now,
153
+ updatedAt: patch.updatedAt || patch.createdAt || now,
154
+ };
155
+ }
156
+ /**
157
+ * Merges an imported patch into an existing local case. Notes, tags, and
158
+ * evidence history are merged additively (never destructive); a scalar field is
159
+ * only overwritten when the import provided a valid value AND is strictly newer
160
+ * than the local record. A patch with no/invalid updatedAt is never newer.
161
+ * @param {CaseRecord} local
162
+ * @param {ImportPatch} patch
163
+ * @returns {CaseRecord}
164
+ */
165
+ function applyImportPatch(local, patch) {
166
+ const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
167
+ const fallback = patch.updatedAt || local.updatedAt;
168
+ const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
169
+ const pinIds = new Set(evidencePins.map((item) => item.id));
170
+ return {
171
+ ...local,
172
+ status: patch.status !== undefined && importNewer ? patch.status : local.status,
173
+ disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
174
+ source: patch.source !== undefined && importNewer ? patch.source : local.source,
175
+ evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
176
+ evidencePins,
177
+ decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
178
+ actions: mergeCaseActions(local.actions, patch.actions, fallback),
179
+ assertions: mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds),
180
+ manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
181
+ sightings: mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds),
182
+ tags: normalizeTags([...local.tags, ...patch.tags]),
183
+ notes: unionNotes(local.notes, patch.notes),
184
+ createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
185
+ updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
186
+ };
187
+ }
188
+ function pickFreeId(preferred, domain, used) {
189
+ const wanted = safeId(preferred);
190
+ let id = wanted && !used.has(wanted) ? wanted : deterministicId(domain);
191
+ const base = deterministicId(domain);
192
+ let suffix = 2;
193
+ while (used.has(id))
194
+ id = `${base}-${suffix++}`;
195
+ return id;
196
+ }
197
+ /**
198
+ * Merges an imported (already parsed) value into the local cases. Predictable
199
+ * and idempotent: unknown records are skipped, existing domains merge without
200
+ * losing newer local decisions, and new ones are added until the store bound is
201
+ * reached. An imported envelope that declares a schema version newer than we
202
+ * support is rejected up front (before any local data is touched) rather than
203
+ * reinterpreted.
204
+ * @param {CaseRecord[]} localCases
205
+ * @param {unknown} importedRaw
206
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number }}
207
+ */
208
+ export function mergeCases(localCases, importedRaw) {
209
+ const importedVersion = parseStoreVersion(importedRaw);
210
+ if (importedVersion !== null && Number.isInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
211
+ throw new Error(`This case file was exported by a newer version of WHOISleuth (schema ${importedVersion}). Update the app before importing it.`);
212
+ }
213
+ if (!CASE_IMPORT_VERSIONS.includes(importedVersion)
214
+ || !importedRaw || typeof importedRaw !== 'object'
215
+ || !Array.isArray(objectRecord(importedRaw).cases)) {
216
+ throw new Error(`Expected a WHOISleuth case export using schema ${CASE_IMPORT_VERSIONS.join(' or ')}.`);
217
+ }
218
+ const local = normalizeCaseStore(localCases).cases;
219
+ const byDomain = new Map(local.map((item) => [item.domain, item]));
220
+ const usedIds = new Set(local.map((item) => item.id));
221
+ let added = 0;
222
+ let updated = 0;
223
+ let skipped = 0;
224
+ const now = new Date().toISOString();
225
+ for (const item of asCaseList(importedRaw)) {
226
+ const patch = extractImportPatch(item);
227
+ if (!patch) {
228
+ skipped += 1;
229
+ continue;
230
+ }
231
+ const existing = byDomain.get(patch.domain);
232
+ if (existing) {
233
+ byDomain.set(patch.domain, applyImportPatch(existing, patch));
234
+ updated += 1;
235
+ }
236
+ else if (byDomain.size < MAX_CASES) {
237
+ const record = caseFromPatch(patch, now);
238
+ record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
239
+ usedIds.add(record.id);
240
+ byDomain.set(patch.domain, record);
241
+ added += 1;
242
+ }
243
+ else {
244
+ skipped += 1;
245
+ }
246
+ }
247
+ return { cases: normalizeCaseStore([...byDomain.values()]).cases, added, updated, skipped };
248
+ }
@@ -0,0 +1,5 @@
1
+ // Stable case-domain facade. Records, migrations, and storage concerns remain
2
+ // separate internally while callers retain one public contract.
3
+ export * from "./case-record-model.js";
4
+ export * from "./case-migration-model.js";
5
+ export * from "./case-storage-model.js";
@@ -0,0 +1,44 @@
1
+ export const CASE_SCHEMA_VERSION = 9;
2
+ export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, CASE_SCHEMA_VERSION];
3
+ export const MAX_CASES = 500;
4
+ export const MAX_NOTES_PER_CASE = 50;
5
+ export const MAX_NOTE_LENGTH = 2000;
6
+ export const MAX_TAGS_PER_CASE = 20;
7
+ export const MAX_TAG_LENGTH = 40;
8
+ export const MAX_DOMAIN_LENGTH = 253;
9
+ export const MAX_CASE_IMPORT_BYTES = 2 * 1024 * 1024;
10
+ export const MAX_EVIDENCE_SNAPSHOTS_PER_CASE = 25;
11
+ export const MAX_EVIDENCE_FACTORS = 20;
12
+ export const MAX_EVIDENCE_NAMESERVERS = 12;
13
+ export const MAX_EVIDENCE_MUTATIONS = 20;
14
+ export const MAX_EVIDENCE_STRING_LENGTH = 200;
15
+ export const MAX_EVIDENCE_TITLE_LENGTH = 200;
16
+ export const MAX_EVIDENCE_DETAIL_LENGTH = 200;
17
+ export const MAX_EVIDENCE_CHANGES = 40;
18
+ export const MAX_CASE_STORE_BYTES = 4 * 1024 * 1024;
19
+ export const CASE_STATUSES = [
20
+ { value: 'new', label: 'New' },
21
+ { value: 'reviewing', label: 'Reviewing' },
22
+ { value: 'monitoring', label: 'Monitoring' },
23
+ { value: 'escalated', label: 'Escalated' },
24
+ { value: 'resolved', label: 'Resolved' },
25
+ ];
26
+ export const CASE_DISPOSITIONS = [
27
+ { value: 'unreviewed', label: 'Unreviewed' },
28
+ { value: 'suspicious', label: 'Suspicious' },
29
+ { value: 'confirmed_abuse', label: 'Confirmed abuse' },
30
+ { value: 'false_positive', label: 'False positive' },
31
+ { value: 'expected', label: 'Expected' },
32
+ { value: 'closed_no_action', label: 'Closed without action' },
33
+ ];
34
+ export const CASE_SOURCES = [
35
+ { value: 'lookup', label: 'Lookup' },
36
+ { value: 'bulk', label: 'Bulk' },
37
+ { value: 'monitor', label: 'Monitor' },
38
+ { value: 'manual', label: 'Manual' },
39
+ { value: 'unknown', label: 'Unknown' },
40
+ ];
41
+ export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
42
+ export const DEFAULT_STATUS = 'new';
43
+ export const DEFAULT_DISPOSITION = 'unreviewed';
44
+ export const DEFAULT_SOURCE = 'unknown';
@@ -0,0 +1,232 @@
1
+ // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
+ // record normalization, and analyst updates.
3
+ import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
4
+ // Forward-version policy (two distinct guarantees):
5
+ // - A locally-stored envelope that declares a version greater than this is
6
+ // still read best-effort on load (known fields kept, unknown dropped), but
7
+ // is never OVERWRITTEN or exported as a downgraded backup (the storage
8
+ // wrapper blocks the write/export). "Not overwritten", not "not read".
9
+ // - An IMPORT file that declares a greater version is never INTERPRETED at
10
+ // all: mergeCases rejects it up front so we don't merge data from a schema
11
+ // we don't understand.
12
+ // The provenance recorded on an individual evidence snapshot. Distinct from a
13
+ // case's `source`: a snapshot can be imported, and a case opened by hand
14
+ // ('manual') has no snapshot provenance of its own.
15
+ export const EVIDENCE_SOURCE_SET = new Set(EVIDENCE_SOURCES);
16
+ export const DEFAULT_EVIDENCE_SOURCE = 'unknown';
17
+ // Deterministic "more informative source wins" order used when a materially
18
+ // identical capture is seen again from a different source. A direct scan beats
19
+ // a monitor bookmark beats a second-hand import beats unknown.
20
+ export const EVIDENCE_SOURCE_RANK = {
21
+ lookup: 4,
22
+ bulk: 4,
23
+ monitor: 2,
24
+ import: 1,
25
+ unknown: 0,
26
+ };
27
+ const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
28
+ const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
29
+ const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
30
+ const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
31
+ const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
32
+ const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
33
+ // Availability tokens that actually assert something about the domain. Anything
34
+ // else ('unknown', 'error', empty) is not, on its own, material evidence.
35
+ export const CONCLUSIVE_AVAILABILITY = new Set([
36
+ 'available',
37
+ 'registered',
38
+ 'for_sale',
39
+ 'expiring',
40
+ ]);
41
+ export const REGISTERED_LIKE = new Set([
42
+ 'registered',
43
+ 'for_sale',
44
+ 'expiring',
45
+ ]);
46
+ // URL/DOM/query-string-safe id shape. UUIDs satisfy this; anything else is
47
+ // treated as untrusted and deterministically repaired.
48
+ const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
49
+ export function objectRecord(value) {
50
+ return value && typeof value === 'object' && !Array.isArray(value)
51
+ ? value
52
+ : {};
53
+ }
54
+ export function statusLabel(value) {
55
+ return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
56
+ }
57
+ export function dispositionLabel(value) {
58
+ return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
59
+ }
60
+ export function sourceLabel(value) {
61
+ return (typeof value === 'string' ? SOURCE_LABELS[value] : '') || String(value || '');
62
+ }
63
+ export function isValidStatus(value) {
64
+ return typeof value === 'string' && STATUS_VALUES.has(value);
65
+ }
66
+ export function isValidDisposition(value) {
67
+ return typeof value === 'string' && DISPOSITION_VALUES.has(value);
68
+ }
69
+ /** Fresh, safe, effectively-unique id for a brand-new local record. */
70
+ export function makeId() {
71
+ if (typeof globalThis.crypto?.randomUUID === 'function')
72
+ return globalThis.crypto.randomUUID();
73
+ return `case-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
74
+ }
75
+ export function safeId(value) {
76
+ return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : null;
77
+ }
78
+ // Deterministic 32-bit FNV-1a hash -> base36, so a repaired id or evidence
79
+ // fingerprint is a pure function of its input (stable across normalization).
80
+ export function hashString(value) {
81
+ let hash = 2166136261 >>> 0;
82
+ for (let index = 0; index < value.length; index++) {
83
+ hash ^= value.charCodeAt(index);
84
+ hash = Math.imul(hash, 16777619) >>> 0;
85
+ }
86
+ return hash.toString(36);
87
+ }
88
+ export function deterministicId(domain) {
89
+ return `c-${hashString(domain)}`;
90
+ }
91
+ /**
92
+ * Strict, canonical domain normalization. Parses through the WHATWG URL host
93
+ * (which strips scheme/path/port/userinfo and applies IDNA/punycode so Unicode
94
+ * and its punycode form collapse to one value), lowercases, drops a single
95
+ * terminal root dot, and validates LDH hostname labels. Rejects IPs, ASNs,
96
+ * whitespace/control characters, underscores, empty/overlong/hyphen-edged
97
+ * labels, and undotted names. Returns '' for anything unusable.
98
+ * @param {unknown} value
99
+ * @returns {string}
100
+ */
101
+ export function normalizeDomain(value) {
102
+ const raw = String(value == null ? '' : value).trim();
103
+ if (!raw || /[\s\x00-\x1f\x7f]/.test(raw))
104
+ return '';
105
+ let hostname;
106
+ try {
107
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
108
+ hostname = new URL(hasScheme ? raw : `http://${raw}`).hostname;
109
+ }
110
+ catch {
111
+ return '';
112
+ }
113
+ hostname = hostname.toLowerCase().replace(/\.$/, '');
114
+ if (!hostname || hostname.length > MAX_DOMAIN_LENGTH)
115
+ return '';
116
+ // A leftover ':' or '[' means a port/IPv6 the host parser preserved.
117
+ if (hostname.includes(':') || hostname.startsWith('['))
118
+ return '';
119
+ const labels = hostname.split('.');
120
+ if (labels.length < 2)
121
+ return '';
122
+ const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
123
+ for (const label of labels) {
124
+ if (!label || label.length > 63 || !labelPattern.test(label))
125
+ return '';
126
+ }
127
+ // An all-numeric final label is an IPv4 address, never a hostname TLD.
128
+ if (/^[0-9]+$/.test(labels.at(-1) ?? ''))
129
+ return '';
130
+ return hostname;
131
+ }
132
+ export function normalizeStatus(value) {
133
+ return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
134
+ }
135
+ export function normalizeDisposition(value) {
136
+ return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
137
+ }
138
+ export function normalizeSource(value) {
139
+ return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
140
+ }
141
+ /** Parsed ISO string, or null when missing/invalid (used for import ordering). */
142
+ export function isoOrNull(value) {
143
+ if (typeof value === 'string') {
144
+ const parsed = Date.parse(value);
145
+ if (!Number.isNaN(parsed))
146
+ return new Date(parsed).toISOString();
147
+ }
148
+ return null;
149
+ }
150
+ export function isoOrNow(value, fallback) {
151
+ return isoOrNull(value) || fallback;
152
+ }
153
+ /**
154
+ * @param {unknown} value
155
+ * @returns {string[]}
156
+ */
157
+ export function normalizeTags(value) {
158
+ if (!Array.isArray(value))
159
+ return [];
160
+ const seen = new Set();
161
+ const tags = [];
162
+ for (const raw of value) {
163
+ const tag = String(raw == null ? '' : raw).trim().slice(0, MAX_TAG_LENGTH);
164
+ if (!tag)
165
+ continue;
166
+ const key = tag.toLowerCase();
167
+ if (seen.has(key))
168
+ continue;
169
+ seen.add(key);
170
+ tags.push(tag);
171
+ if (tags.length >= MAX_TAGS_PER_CASE)
172
+ break;
173
+ }
174
+ return tags;
175
+ }
176
+ /**
177
+ * @param {unknown} body
178
+ * @returns {string}
179
+ */
180
+ export function normalizeNoteBody(body) {
181
+ return String(body == null ? '' : body).trim().slice(0, MAX_NOTE_LENGTH);
182
+ }
183
+ /**
184
+ * Normalizes one note deterministically. `createdAt` is the note's own valid
185
+ * timestamp, else the supplied `fallback`; a note with neither is skipped rather
186
+ * than stamped with an arbitrary time. Repaired ids are a pure function of the
187
+ * body plus the resolved timestamp, so re-importing the same note produces the
188
+ * same id (and therefore dedupes) instead of a fresh one.
189
+ * @param {unknown} raw
190
+ * @param {string | null} fallback
191
+ * @returns {CaseNote | null}
192
+ */
193
+ function normalizeNote(raw, fallback) {
194
+ const record = objectRecord(raw);
195
+ const body = normalizeNoteBody(record.body);
196
+ if (!body)
197
+ return null;
198
+ const createdAt = isoOrNull(record.createdAt) || fallback;
199
+ if (!createdAt)
200
+ return null;
201
+ return {
202
+ id: safeId(record.id) || `n-${hashString(`${body}|${createdAt}`)}`,
203
+ body,
204
+ createdAt,
205
+ };
206
+ }
207
+ /**
208
+ * @param {unknown} value
209
+ * @param {string | null} fallback timestamp for notes lacking their own (the
210
+ * genuine "now" for local recovery; only the imported record's own createdAt/
211
+ * updatedAt for imports, never the current time)
212
+ * @returns {CaseNote[]}
213
+ */
214
+ export function normalizeNotes(value, fallback) {
215
+ if (!Array.isArray(value))
216
+ return [];
217
+ const seen = new Set();
218
+ const notes = [];
219
+ for (const raw of value) {
220
+ const note = normalizeNote(raw, fallback);
221
+ if (!note || seen.has(note.id))
222
+ continue;
223
+ seen.add(note.id);
224
+ notes.push(note);
225
+ }
226
+ notes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
227
+ // Keep the most recent notes when over the bound.
228
+ return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // Evidence snapshot normalization
232
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,7 @@
1
+ // Stable case-record facade. Core validation, evidence history/comparison,
2
+ // and analyst-record operations remain framework-neutral and independently
3
+ // testable while existing consumers keep one compatibility import.
4
+ export * from "./case-record-contracts.js";
5
+ export * from "./case-record-core.js";
6
+ export * from "./case-evidence-model.js";
7
+ export * from "./case-record-operations.js";