@slicedearth/whoisleuth-cli 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,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";
|