@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -0,0 +1,476 @@
|
|
|
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, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
|
|
2
|
+
import { CASE_REPORT_SCHEMA, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
|
|
3
|
+
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
4
|
+
import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
|
|
5
|
+
import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
6
|
+
import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
|
|
7
|
+
import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseAssertions, mergeCaseClosureHistories, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseObservedEffectHistories, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
|
|
8
|
+
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
9
|
+
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
10
|
+
const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
|
|
11
|
+
export const MAX_CASE_INPUT_RECORDS = 2_000;
|
|
12
|
+
function compareCodeUnits(left, right) {
|
|
13
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
14
|
+
}
|
|
15
|
+
const CASE_INPUT_JSON_LIMITS = Object.freeze({
|
|
16
|
+
maximumDepth: 48,
|
|
17
|
+
maximumKeys: 500_000,
|
|
18
|
+
maximumValues: 1_000_000,
|
|
19
|
+
maximumContainerItems: 10_000,
|
|
20
|
+
});
|
|
21
|
+
function assignUniqueIds(cases) {
|
|
22
|
+
const used = new Set();
|
|
23
|
+
for (const record of [...cases].sort((a, b) => compareCodeUnits(a.domain, b.domain))) {
|
|
24
|
+
let id = safeId(record.id) || deterministicId(record.domain);
|
|
25
|
+
if (used.has(id)) {
|
|
26
|
+
const base = deterministicId(record.domain);
|
|
27
|
+
id = base;
|
|
28
|
+
let suffix = 2;
|
|
29
|
+
while (used.has(id))
|
|
30
|
+
id = `${base}-${suffix++}`;
|
|
31
|
+
}
|
|
32
|
+
used.add(id);
|
|
33
|
+
record.id = id;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Recovers a clean, bounded store from the current versioned envelope or an
|
|
38
|
+
* internal bare array, drops malformed records, keeps a single
|
|
39
|
+
* case per domain (most recently updated wins), caps to MAX_CASES by recency,
|
|
40
|
+
* and guarantees globally unique safe ids. Retired, future, accessor-bearing,
|
|
41
|
+
* sparse, or otherwise non-JSON envelopes fail before normalisation.
|
|
42
|
+
* @param {unknown} raw
|
|
43
|
+
* @returns {CaseStore}
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeCaseStore(raw) {
|
|
46
|
+
assertBoundedJsonStructure(raw, 'Case store', CASE_INPUT_JSON_LIMITS);
|
|
47
|
+
const fallback = new Date(0).toISOString();
|
|
48
|
+
const sourceVersion = parseStoreVersion(raw);
|
|
49
|
+
if (!Array.isArray(raw)) {
|
|
50
|
+
if (sourceVersion === null) {
|
|
51
|
+
throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
52
|
+
}
|
|
53
|
+
if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
|
|
54
|
+
throw new TypeError(`Case schema ${sourceVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
55
|
+
}
|
|
56
|
+
if (sourceVersion > CASE_SCHEMA_VERSION) {
|
|
57
|
+
throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
58
|
+
}
|
|
59
|
+
if (sourceVersion === CASE_SCHEMA_VERSION) {
|
|
60
|
+
assertCurrentEvidenceHostnameShape(raw);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const byDomain = new Map();
|
|
64
|
+
for (const item of boundedCaseList(raw).items) {
|
|
65
|
+
const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
|
|
66
|
+
if (!normalized)
|
|
67
|
+
continue;
|
|
68
|
+
const existing = byDomain.get(normalized.domain);
|
|
69
|
+
if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
|
|
70
|
+
byDomain.set(normalized.domain, normalized);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const cases = [...byDomain.values()]
|
|
74
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
75
|
+
.slice(0, MAX_CASES);
|
|
76
|
+
assignUniqueIds(cases);
|
|
77
|
+
return { version: CASE_SCHEMA_VERSION, cases };
|
|
78
|
+
}
|
|
79
|
+
function assertCurrentEvidenceHostnameShape(raw) {
|
|
80
|
+
for (const item of boundedCaseList(raw).items) {
|
|
81
|
+
const evidenceHistory = objectRecord(item).evidenceHistory;
|
|
82
|
+
if (!Array.isArray(evidenceHistory))
|
|
83
|
+
continue;
|
|
84
|
+
for (const snapshot of evidenceHistory) {
|
|
85
|
+
const record = objectRecord(snapshot);
|
|
86
|
+
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
|
|
87
|
+
throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The schema version declared by a stored/parsed value, or null. The storage
|
|
94
|
+
* wrapper uses this to refuse overwriting data written by a newer, unsupported
|
|
95
|
+
* version instead of silently downgrading it.
|
|
96
|
+
* @param {unknown} raw
|
|
97
|
+
* @returns {number | null}
|
|
98
|
+
*/
|
|
99
|
+
export function parseStoreVersion(raw) {
|
|
100
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
101
|
+
return null;
|
|
102
|
+
let descriptor;
|
|
103
|
+
try {
|
|
104
|
+
descriptor = Object.getOwnPropertyDescriptor(raw, 'version');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new TypeError('The Case store version could not be inspected safely.');
|
|
108
|
+
}
|
|
109
|
+
if (!descriptor)
|
|
110
|
+
return null;
|
|
111
|
+
if (!Object.hasOwn(descriptor, 'value')) {
|
|
112
|
+
throw new TypeError('The Case store version must not be an accessor.');
|
|
113
|
+
}
|
|
114
|
+
return typeof descriptor.value === 'number' ? descriptor.value : null;
|
|
115
|
+
}
|
|
116
|
+
/** @param {unknown} raw @returns {unknown[]} */
|
|
117
|
+
function asCaseList(raw) {
|
|
118
|
+
if (Array.isArray(raw))
|
|
119
|
+
return raw;
|
|
120
|
+
const cases = objectRecord(raw).cases;
|
|
121
|
+
if (Array.isArray(cases))
|
|
122
|
+
return cases;
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
function boundedCaseList(raw) {
|
|
126
|
+
const source = asCaseList(raw);
|
|
127
|
+
return {
|
|
128
|
+
items: source.slice(0, MAX_CASE_INPUT_RECORDS),
|
|
129
|
+
omitted: Math.max(0, source.length - MAX_CASE_INPUT_RECORDS),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
|
|
134
|
+
* @param {string} [nowIso]
|
|
135
|
+
* @returns {CaseRecord}
|
|
136
|
+
*/
|
|
137
|
+
function importScalar(value, valid) {
|
|
138
|
+
return typeof value === 'string' && valid.has(value) ? value : undefined;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Validates one imported record into a patch. Unlike normalizeCase, absent or
|
|
142
|
+
* invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
|
|
143
|
+
* updatedAt stays `null` (treated as older than any real local timestamp), so an
|
|
144
|
+
* incomplete import can never win a merge over valid local data. Imported
|
|
145
|
+
* evidence is normalized additively; a snapshot with no captured time falls back
|
|
146
|
+
* only to the imported record's own (older) timestamps, never to "now".
|
|
147
|
+
* @param {unknown} raw
|
|
148
|
+
* @returns {ImportPatch | null}
|
|
149
|
+
*/
|
|
150
|
+
function extractImportPatch(raw, importedVersion) {
|
|
151
|
+
const record = objectRecord(raw);
|
|
152
|
+
const domain = normalizeDomain(record.domain);
|
|
153
|
+
if (!domain)
|
|
154
|
+
return null;
|
|
155
|
+
const importFallback = caseTimestampOrNull(record.updatedAt, importedVersion)
|
|
156
|
+
|| caseTimestampOrNull(record.createdAt, importedVersion)
|
|
157
|
+
|| null;
|
|
158
|
+
const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
|
|
159
|
+
const timestampOptions = {
|
|
160
|
+
legacyTimestamps: importedVersion < CASE_SCHEMA_VERSION,
|
|
161
|
+
sourceVersion: importedVersion,
|
|
162
|
+
};
|
|
163
|
+
const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
164
|
+
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback, {
|
|
165
|
+
allowCertificateObservation: importedVersion >= 11,
|
|
166
|
+
...timestampOptions,
|
|
167
|
+
});
|
|
168
|
+
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
169
|
+
const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
|
|
170
|
+
const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
|
|
171
|
+
const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
|
|
172
|
+
const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
|
|
173
|
+
const closures = normalizeCaseClosureHistory(importedVersion >= 13 ? record.closures : undefined, normalizedFallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
|
|
174
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
175
|
+
const brandProfileReferences = importedVersion >= 12
|
|
176
|
+
? inspectCaseBrandProfileIds(record.brandProfileIds)
|
|
177
|
+
: { ids: [], omitted: 0 };
|
|
178
|
+
return {
|
|
179
|
+
domain,
|
|
180
|
+
rawId: typeof record.id === 'string' ? record.id : null,
|
|
181
|
+
status: importScalar(record.status, STATUS_VALUES),
|
|
182
|
+
disposition: importScalar(record.disposition, DISPOSITION_VALUES),
|
|
183
|
+
reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
|
|
184
|
+
? normalizeReviewReasonCode(record.reviewReasonCode)
|
|
185
|
+
: undefined,
|
|
186
|
+
brandProfileIds: brandProfileReferences.ids,
|
|
187
|
+
brandProfileReferencesOmitted: brandProfileReferences.omitted,
|
|
188
|
+
source: importScalar(record.source, SOURCE_VALUES),
|
|
189
|
+
evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
|
|
190
|
+
source: 'import',
|
|
191
|
+
fallback: importFallback,
|
|
192
|
+
sourceVersion: importedVersion,
|
|
193
|
+
caseDomain: domain,
|
|
194
|
+
}),
|
|
195
|
+
evidencePins,
|
|
196
|
+
decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions),
|
|
197
|
+
actions,
|
|
198
|
+
assertions,
|
|
199
|
+
manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions),
|
|
200
|
+
sightings,
|
|
201
|
+
observedEffects,
|
|
202
|
+
closures,
|
|
203
|
+
branches: importedVersion >= 11
|
|
204
|
+
? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
|
|
205
|
+
: [],
|
|
206
|
+
tags: normalizeTags(record.tags),
|
|
207
|
+
// Imported notes fall back only to the imported record's own timestamps
|
|
208
|
+
// (never "now"), so a timestamp-less note gets a stable, deterministic time
|
|
209
|
+
// and id, and re-importing the same file cannot manufacture a duplicate or a
|
|
210
|
+
// spuriously-newer note.
|
|
211
|
+
notes: normalizeNotes(record.notes, importFallback, importedVersion),
|
|
212
|
+
createdAt: caseTimestampOrNull(record.createdAt, importedVersion),
|
|
213
|
+
updatedAt: caseTimestampOrNull(record.updatedAt, importedVersion),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
|
|
217
|
+
function unionNotes(a, b) {
|
|
218
|
+
const byId = new Map();
|
|
219
|
+
for (const note of [...a, ...b]) {
|
|
220
|
+
if (!byId.has(note.id))
|
|
221
|
+
byId.set(note.id, note);
|
|
222
|
+
}
|
|
223
|
+
const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
|
224
|
+
return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
|
|
225
|
+
}
|
|
226
|
+
// Additive, deduplicated union of two evidence histories. Identical material
|
|
227
|
+
// collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
|
|
228
|
+
// are retained subject to the per-case bound, and an older import can never
|
|
229
|
+
// move an existing observation backwards.
|
|
230
|
+
function mergeEvidenceHistories(local, imported, caseDomain) {
|
|
231
|
+
return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null, caseDomain });
|
|
232
|
+
}
|
|
233
|
+
/** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
|
|
234
|
+
function caseFromPatch(patch, now) {
|
|
235
|
+
return {
|
|
236
|
+
id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
|
|
237
|
+
domain: patch.domain,
|
|
238
|
+
status: patch.status ?? DEFAULT_STATUS,
|
|
239
|
+
disposition: patch.disposition ?? DEFAULT_DISPOSITION,
|
|
240
|
+
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
241
|
+
brandProfileIds: patch.brandProfileIds,
|
|
242
|
+
tags: patch.tags,
|
|
243
|
+
notes: patch.notes,
|
|
244
|
+
source: patch.source ?? DEFAULT_SOURCE,
|
|
245
|
+
evidenceHistory: patch.evidenceHistory,
|
|
246
|
+
evidencePins: patch.evidencePins,
|
|
247
|
+
decisions: patch.decisions,
|
|
248
|
+
actions: patch.actions,
|
|
249
|
+
assertions: patch.assertions,
|
|
250
|
+
manualTrail: patch.manualTrail,
|
|
251
|
+
sightings: patch.sightings,
|
|
252
|
+
observedEffects: patch.observedEffects,
|
|
253
|
+
closures: patch.closures,
|
|
254
|
+
branches: patch.branches,
|
|
255
|
+
createdAt: patch.createdAt || patch.updatedAt || now,
|
|
256
|
+
updatedAt: patch.updatedAt || patch.createdAt || now,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Merges an imported patch into an existing local case. Notes, tags, and
|
|
261
|
+
* evidence history are merged additively (never destructive); a scalar field is
|
|
262
|
+
* only overwritten when the import provided a valid value AND is strictly newer
|
|
263
|
+
* than the local record. A patch with no/invalid updatedAt is never newer.
|
|
264
|
+
* @param {CaseRecord} local
|
|
265
|
+
* @param {ImportPatch} patch
|
|
266
|
+
* @returns {CaseRecord}
|
|
267
|
+
*/
|
|
268
|
+
function applyImportPatch(local, patch) {
|
|
269
|
+
const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
|
|
270
|
+
const fallback = patch.updatedAt || local.updatedAt;
|
|
271
|
+
const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
|
|
272
|
+
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
273
|
+
const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
|
|
274
|
+
const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
|
|
275
|
+
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
276
|
+
const sightings = mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds);
|
|
277
|
+
const sightingIds = new Set(sightings.map((item) => item.id));
|
|
278
|
+
const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
|
|
279
|
+
const closures = mergeCaseClosureHistories(local.closures, patch.closures, fallback, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), buildCaseClosureLinkContext(observedEffects, actions));
|
|
280
|
+
const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
|
|
281
|
+
return { record: {
|
|
282
|
+
...local,
|
|
283
|
+
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
284
|
+
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
285
|
+
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
286
|
+
brandProfileIds: brandProfileReferences.ids,
|
|
287
|
+
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
288
|
+
evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
|
|
289
|
+
evidencePins,
|
|
290
|
+
decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
|
|
291
|
+
actions,
|
|
292
|
+
assertions,
|
|
293
|
+
manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
|
|
294
|
+
sightings,
|
|
295
|
+
observedEffects,
|
|
296
|
+
closures,
|
|
297
|
+
branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
|
|
298
|
+
tags: normalizeTags([...local.tags, ...patch.tags]),
|
|
299
|
+
notes: unionNotes(local.notes, patch.notes),
|
|
300
|
+
createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
|
|
301
|
+
updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
|
|
302
|
+
}, brandProfileReferencesOmitted: brandProfileReferences.omitted };
|
|
303
|
+
}
|
|
304
|
+
function pickFreeId(preferred, domain, used) {
|
|
305
|
+
const wanted = safeId(preferred);
|
|
306
|
+
let id = wanted && !used.has(wanted) ? wanted : deterministicId(domain);
|
|
307
|
+
const base = deterministicId(domain);
|
|
308
|
+
let suffix = 2;
|
|
309
|
+
while (used.has(id))
|
|
310
|
+
id = `${base}-${suffix++}`;
|
|
311
|
+
return id;
|
|
312
|
+
}
|
|
313
|
+
const CASE_EXPORT_ROOT_KEYS = Object.freeze(['version', 'exportedAt', 'cases']);
|
|
314
|
+
function assertOnlyEnvelopeKeys(value, allowed, label) {
|
|
315
|
+
const allowedKeys = new Set(allowed);
|
|
316
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
317
|
+
throw new Error(`The WHOISleuth ${label} contains undeclared envelope fields.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function exactStringList(value, expected) {
|
|
321
|
+
return Array.isArray(value)
|
|
322
|
+
&& value.length === expected.length
|
|
323
|
+
&& value.every((item, index) => item === expected[index]);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* A CLI Case-pack remains browser-importable through its exact top-level Case
|
|
327
|
+
* collection. This projection closes the known pack envelopes before dropping
|
|
328
|
+
* pack-only review and integrity metadata; it does not make an integrity claim.
|
|
329
|
+
* The statically bound CLI verifier remains the exact structure-and-digest
|
|
330
|
+
* verifier for the durable Case-pack version.
|
|
331
|
+
*/
|
|
332
|
+
function caseCollectionImportEnvelope(importedRaw) {
|
|
333
|
+
const root = objectRecord(importedRaw);
|
|
334
|
+
const hasPackField = Object.hasOwn(root, 'packet') || Object.hasOwn(root, 'integrity');
|
|
335
|
+
if (!hasPackField) {
|
|
336
|
+
assertOnlyEnvelopeKeys(root, CASE_EXPORT_ROOT_KEYS, 'case export');
|
|
337
|
+
return root;
|
|
338
|
+
}
|
|
339
|
+
assertOnlyEnvelopeKeys(root, CLI_CASE_PACK_ROOT_KEYS, 'CLI Case-pack');
|
|
340
|
+
if (CLI_CASE_PACK_ROOT_KEYS.some((key) => !Object.hasOwn(root, key))) {
|
|
341
|
+
throw new Error('The WHOISleuth CLI Case-pack is missing a required envelope field.');
|
|
342
|
+
}
|
|
343
|
+
const packet = objectRecord(root.packet);
|
|
344
|
+
const integrity = objectRecord(root.integrity);
|
|
345
|
+
const redactionManifest = objectRecord(packet.redactionManifest);
|
|
346
|
+
assertOnlyEnvelopeKeys(packet, CLI_CASE_PACK_PACKET_KEYS, 'CLI Case-pack packet');
|
|
347
|
+
assertOnlyEnvelopeKeys(integrity, CLI_CASE_PACK_INTEGRITY_KEYS, 'CLI Case-pack integrity');
|
|
348
|
+
const rootVersion = root.version;
|
|
349
|
+
const packVersion = packet.version;
|
|
350
|
+
if (Number.isSafeInteger(packVersion) && packVersion < CLI_CASE_PACK_VERSION) {
|
|
351
|
+
throw new Error(`WHOISleuth CLI Case-pack version ${String(packVersion)} is retired. Re-export it as version ${CLI_CASE_PACK_VERSION}; no data was changed.`);
|
|
352
|
+
}
|
|
353
|
+
if (Number.isSafeInteger(packVersion) && packVersion > CLI_CASE_PACK_VERSION) {
|
|
354
|
+
throw new Error(`WHOISleuth CLI Case-pack version ${String(packVersion)} is newer than the supported version ${CLI_CASE_PACK_VERSION}; no data was changed.`);
|
|
355
|
+
}
|
|
356
|
+
if (Number.isSafeInteger(rootVersion) && rootVersion < CASE_SCHEMA_VERSION
|
|
357
|
+
&& !CASE_IMPORT_VERSIONS.includes(rootVersion)) {
|
|
358
|
+
throw new Error(`Case schema ${String(rootVersion)} in this CLI Case-pack is not part of the public compatibility boundary; no data was changed.`);
|
|
359
|
+
}
|
|
360
|
+
if (Number.isSafeInteger(rootVersion) && rootVersion > CASE_SCHEMA_VERSION) {
|
|
361
|
+
throw new Error(`Case schema ${String(rootVersion)} in this CLI Case-pack is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
362
|
+
}
|
|
363
|
+
assertOnlyEnvelopeKeys(redactionManifest, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, 'CLI Case-pack redaction manifest');
|
|
364
|
+
const reports = packet.reports;
|
|
365
|
+
const cases = root.cases;
|
|
366
|
+
const audience = packet.audience;
|
|
367
|
+
if (packet.schema !== CLI_CASE_PACK_SCHEMA
|
|
368
|
+
|| packVersion !== CLI_CASE_PACK_VERSION
|
|
369
|
+
|| typeof rootVersion !== 'number'
|
|
370
|
+
|| !CASE_IMPORT_VERSIONS.includes(rootVersion)
|
|
371
|
+
|| packet.reviewed !== true
|
|
372
|
+
|| (audience !== 'internal' && audience !== 'public' && audience !== 'trusted')
|
|
373
|
+
|| !Array.isArray(cases)
|
|
374
|
+
|| !Array.isArray(reports)
|
|
375
|
+
|| reports.length !== cases.length
|
|
376
|
+
|| !exactStringList(packet.limitations, CLI_CASE_PACK_LIMITATIONS)
|
|
377
|
+
|| !Number.isSafeInteger(redactionManifest.sourceCaseCount)
|
|
378
|
+
|| redactionManifest.sourceCaseCount !== cases.length
|
|
379
|
+
|| !Number.isSafeInteger(redactionManifest.brandProfileReferencesOmitted)
|
|
380
|
+
|| redactionManifest.brandProfileReferencesOmitted < 0
|
|
381
|
+
|| integrity.algorithm !== 'SHA-256'
|
|
382
|
+
|| integrity.canonicalization !== 'sorted-json-v2'
|
|
383
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
384
|
+
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)
|
|
385
|
+
|| typeof root.exportedAt !== 'string'
|
|
386
|
+
|| caseTimestampOrNull(root.exportedAt) !== root.exportedAt) {
|
|
387
|
+
throw new Error('The WHOISleuth CLI Case-pack envelope is invalid.');
|
|
388
|
+
}
|
|
389
|
+
for (const reportValue of reports) {
|
|
390
|
+
const report = objectRecord(reportValue);
|
|
391
|
+
assertOnlyEnvelopeKeys(report, rootVersion === CASE_SCHEMA_VERSION ? CLI_CASE_PACK_REPORT_KEYS : CLI_CASE_PACK_PUBLIC_REPORT_KEYS, 'CLI Case-pack report');
|
|
392
|
+
if (report.schema !== CASE_REPORT_SCHEMA
|
|
393
|
+
|| !caseReportVersionMatchesCase(rootVersion, report.schemaVersion)
|
|
394
|
+
|| report.generatedAt !== root.exportedAt) {
|
|
395
|
+
throw new Error('The WHOISleuth CLI Case-pack report epoch is invalid.');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const normalised = normalizeCaseStore(root).cases;
|
|
399
|
+
if (normalised.length !== cases.length
|
|
400
|
+
|| (rootVersion === CASE_SCHEMA_VERSION
|
|
401
|
+
&& canonicalArtifactJsonV2(cases) !== canonicalArtifactJsonV2(normalised))) {
|
|
402
|
+
throw new Error(`The WHOISleuth CLI Case-pack contains a non-canonical schema ${rootVersion} Case collection.`);
|
|
403
|
+
}
|
|
404
|
+
return { version: rootVersion, exportedAt: root.exportedAt, cases };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Merges an imported (already parsed) value into the local cases. Predictable
|
|
408
|
+
* and idempotent: unknown records are skipped, existing domains merge without
|
|
409
|
+
* losing newer local decisions, and new ones are added until the store bound is
|
|
410
|
+
* reached. An imported envelope that declares a schema version newer than we
|
|
411
|
+
* support is rejected up front (before any local data is touched) rather than
|
|
412
|
+
* reinterpreted.
|
|
413
|
+
* @param {CaseRecord[]} localCases
|
|
414
|
+
* @param {unknown} importedRaw
|
|
415
|
+
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
|
|
416
|
+
*/
|
|
417
|
+
export function mergeCases(localCases, importedRaw) {
|
|
418
|
+
assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
|
|
419
|
+
const importedEnvelope = caseCollectionImportEnvelope(importedRaw);
|
|
420
|
+
const importedVersion = parseStoreVersion(importedEnvelope);
|
|
421
|
+
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
|
|
422
|
+
&& !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
|
|
423
|
+
throw new Error(`Case schema ${importedVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
424
|
+
}
|
|
425
|
+
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
|
|
426
|
+
throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
427
|
+
}
|
|
428
|
+
if (!CASE_IMPORT_VERSIONS.includes(importedVersion)
|
|
429
|
+
|| !Array.isArray(importedEnvelope.cases)) {
|
|
430
|
+
throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
431
|
+
}
|
|
432
|
+
if (importedVersion === CASE_SCHEMA_VERSION)
|
|
433
|
+
assertCurrentEvidenceHostnameShape(importedEnvelope);
|
|
434
|
+
const local = normalizeCaseStore(localCases).cases;
|
|
435
|
+
const supportedImportedVersion = importedVersion ?? 0;
|
|
436
|
+
const byDomain = new Map(local.map((item) => [item.domain, item]));
|
|
437
|
+
const usedIds = new Set(local.map((item) => item.id));
|
|
438
|
+
let added = 0;
|
|
439
|
+
let updated = 0;
|
|
440
|
+
const imported = boundedCaseList(importedEnvelope);
|
|
441
|
+
let skipped = imported.omitted;
|
|
442
|
+
let brandProfileReferencesOmitted = 0;
|
|
443
|
+
const fallback = new Date(0).toISOString();
|
|
444
|
+
for (const item of imported.items) {
|
|
445
|
+
const patch = extractImportPatch(item, supportedImportedVersion);
|
|
446
|
+
if (!patch) {
|
|
447
|
+
skipped += 1;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const existing = byDomain.get(patch.domain);
|
|
451
|
+
if (existing) {
|
|
452
|
+
const merged = applyImportPatch(existing, patch);
|
|
453
|
+
byDomain.set(patch.domain, merged.record);
|
|
454
|
+
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
|
|
455
|
+
updated += 1;
|
|
456
|
+
}
|
|
457
|
+
else if (byDomain.size < MAX_CASES) {
|
|
458
|
+
const record = caseFromPatch(patch, fallback);
|
|
459
|
+
record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
|
|
460
|
+
usedIds.add(record.id);
|
|
461
|
+
byDomain.set(patch.domain, record);
|
|
462
|
+
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
|
|
463
|
+
added += 1;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
skipped += 1;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
cases: normalizeCaseStore([...byDomain.values()]).cases,
|
|
471
|
+
added,
|
|
472
|
+
updated,
|
|
473
|
+
skipped,
|
|
474
|
+
brandProfileReferencesOmitted,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Stable case-domain facade. Records, migrations, and storage concerns remain
|
|
2
2
|
// separate internally while callers retain one public contract.
|
|
3
|
-
export * from "./case-record-model.
|
|
4
|
-
export * from "./case-migration-model.
|
|
5
|
-
export * from "./case-storage-model.
|
|
3
|
+
export * from "./case-record-model.mjs";
|
|
4
|
+
export * from "./case-migration-model.mjs";
|
|
5
|
+
export * from "./case-storage-model.mjs";
|
|
@@ -1,24 +1,6 @@
|
|
|
1
|
-
import { ANALYST_REVIEW_REASONS } from "
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
export const MAX_CASES = 500;
|
|
5
|
-
export const MAX_NOTES_PER_CASE = 50;
|
|
6
|
-
export const MAX_NOTE_LENGTH = 2000;
|
|
7
|
-
export const MAX_TAGS_PER_CASE = 20;
|
|
8
|
-
export const MAX_TAG_LENGTH = 40;
|
|
9
|
-
export const MAX_DOMAIN_LENGTH = 253;
|
|
10
|
-
export const MAX_CASE_IMPORT_BYTES = 2 * 1024 * 1024;
|
|
11
|
-
export const MAX_EVIDENCE_SNAPSHOTS_PER_CASE = 25;
|
|
12
|
-
export const MAX_EVIDENCE_FACTORS = 20;
|
|
13
|
-
export const MAX_EVIDENCE_NAMESERVERS = 12;
|
|
14
|
-
export const MAX_EVIDENCE_MUTATIONS = 20;
|
|
15
|
-
export const MAX_EVIDENCE_STRING_LENGTH = 200;
|
|
16
|
-
export const MAX_EVIDENCE_TITLE_LENGTH = 200;
|
|
17
|
-
export const MAX_EVIDENCE_DETAIL_LENGTH = 200;
|
|
18
|
-
export const MAX_EVIDENCE_CHANGES = 40;
|
|
19
|
-
export const MAX_CASE_STORE_BYTES = 4 * 1024 * 1024;
|
|
20
|
-
export const MAX_CASE_BRAND_PROFILE_IDS = 8;
|
|
21
|
-
export const MAX_CASE_BRAND_PROFILE_ID_CANDIDATES = 32;
|
|
1
|
+
import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
|
|
2
|
+
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, 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_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
|
|
3
|
+
export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, 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_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
|
|
22
4
|
export const CASE_STATUSES = [
|
|
23
5
|
{ value: 'new', label: 'New' },
|
|
24
6
|
{ value: 'reviewing', label: 'Reviewing' },
|