@slicedearth/whoisleuth-cli 1.45.0 → 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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- 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 +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- 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 +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -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 +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- 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-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- 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-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- 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 +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- 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/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- 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 -19
- 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} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- 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/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -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/packages/evidence/artifact-integrity.mjs +65 -0
- 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} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- 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/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- 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} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -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} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- 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} +18 -12
- 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} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- 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 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- 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 -232
- 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 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /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,22 +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;
|
|
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, };
|
|
20
4
|
export const CASE_STATUSES = [
|
|
21
5
|
{ value: 'new', label: 'New' },
|
|
22
6
|
{ value: 'reviewing', label: 'Reviewing' },
|
package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs}
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.
|
|
3
|
+
import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
4
6
|
// Forward-version policy (two distinct guarantees):
|
|
5
7
|
// - A locally-stored envelope that declares a version greater than this is
|
|
6
8
|
// still read best-effort on load (known fields kept, unknown dropped), but
|
|
@@ -47,6 +49,11 @@ export const REGISTERED_LIKE = new Set([
|
|
|
47
49
|
// URL/DOM/query-string-safe id shape. UUIDs satisfy this; anything else is
|
|
48
50
|
// treated as untrusted and deterministically repaired.
|
|
49
51
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
52
|
+
// Unicode U-labels can be substantially longer than their canonical A-label
|
|
53
|
+
// representation (for example, decomposed combining sequences). Keep the raw
|
|
54
|
+
// work bound finite, then enforce DNS' 253/63-byte limits on the URL parser's
|
|
55
|
+
// canonical ASCII hostname below.
|
|
56
|
+
export const MAX_EVIDENCE_DOMAIN_INPUT_LENGTH = 1024;
|
|
50
57
|
export function objectRecord(value) {
|
|
51
58
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
52
59
|
? value
|
|
@@ -89,47 +96,56 @@ export function hashString(value) {
|
|
|
89
96
|
export function deterministicId(domain) {
|
|
90
97
|
return `c-${hashString(domain)}`;
|
|
91
98
|
}
|
|
99
|
+
export { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
92
100
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* whitespace/control characters, underscores, empty/overlong/hyphen-edged
|
|
98
|
-
* labels, and undotted names. Returns '' for anything unusable.
|
|
99
|
-
* @param {unknown} value
|
|
100
|
-
* @returns {string}
|
|
101
|
+
* Canonicalizes a hostname supplied as source evidence without applying the
|
|
102
|
+
* analyst-input conveniences in normalizeDomain. URI syntax, credentials,
|
|
103
|
+
* ports, paths, queries, fragments, separators, whitespace, and controls make
|
|
104
|
+
* the observation invalid rather than being silently discarded.
|
|
101
105
|
*/
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
export function normalizeEvidenceDomain(value) {
|
|
107
|
+
if (typeof value !== 'string' || !value || value !== value.trim())
|
|
108
|
+
return '';
|
|
109
|
+
if (value.length > MAX_EVIDENCE_DOMAIN_INPUT_LENGTH)
|
|
110
|
+
return '';
|
|
111
|
+
if (/[\s\x00-\x1f\x7f%@/:?#\\]/u.test(value))
|
|
105
112
|
return '';
|
|
106
|
-
|
|
113
|
+
const withoutRootDot = value.endsWith('.') ? value.slice(0, -1) : value;
|
|
114
|
+
if (!withoutRootDot || withoutRootDot.endsWith('.'))
|
|
115
|
+
return '';
|
|
116
|
+
let hostname = '';
|
|
107
117
|
try {
|
|
108
|
-
|
|
109
|
-
hostname = new URL(hasScheme ? raw : `http://${raw}`).hostname;
|
|
118
|
+
hostname = new URL(`http://${withoutRootDot}`).hostname.toLowerCase();
|
|
110
119
|
}
|
|
111
120
|
catch {
|
|
112
121
|
return '';
|
|
113
122
|
}
|
|
114
|
-
hostname
|
|
115
|
-
if (!hostname || hostname.length > MAX_DOMAIN_LENGTH)
|
|
116
|
-
return '';
|
|
117
|
-
// A leftover ':' or '[' means a port/IPv6 the host parser preserved.
|
|
118
|
-
if (hostname.includes(':') || hostname.startsWith('['))
|
|
123
|
+
if (!hostname || hostname.length > MAX_DOMAIN_LENGTH || hostname !== hostname.replace(/\.$/u, ''))
|
|
119
124
|
return '';
|
|
120
125
|
const labels = hostname.split('.');
|
|
121
126
|
if (labels.length < 2)
|
|
122
127
|
return '';
|
|
123
|
-
const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
// An all-numeric final label is an IPv4 address, never a hostname TLD.
|
|
129
|
-
if (/^[0-9]+$/.test(labels.at(-1) ?? ''))
|
|
128
|
+
const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
129
|
+
if (labels.some((label) => !label || label.length > 63 || !labelPattern.test(label)))
|
|
130
|
+
return '';
|
|
131
|
+
if (/^[0-9]+$/u.test(labels.at(-1) ?? ''))
|
|
130
132
|
return '';
|
|
131
133
|
return hostname;
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Retains an exact submitted hostname only when it is strict evidence input
|
|
137
|
+
* for the Case's canonical registrable identity. This boundary deliberately
|
|
138
|
+
* does not repair URI syntax or infer a hostname from other evidence.
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeEvidenceHostnameForCase(value, caseDomain) {
|
|
141
|
+
const hostname = normalizeEvidenceDomain(value);
|
|
142
|
+
const canonicalCase = normalizeEvidenceDomain(caseDomain);
|
|
143
|
+
if (!hostname || !canonicalCase)
|
|
144
|
+
return null;
|
|
145
|
+
if (canonicalRegistrableDomain(canonicalCase) !== canonicalCase)
|
|
146
|
+
return null;
|
|
147
|
+
return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
|
|
148
|
+
}
|
|
133
149
|
export function normalizeStatus(value) {
|
|
134
150
|
return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
|
|
135
151
|
}
|
|
@@ -151,6 +167,17 @@ export function isoOrNull(value) {
|
|
|
151
167
|
}
|
|
152
168
|
return null;
|
|
153
169
|
}
|
|
170
|
+
/** Canonical instant for versioned case data; current schemas require a zone. */
|
|
171
|
+
export function caseTimestampOrNull(value, sourceVersion) {
|
|
172
|
+
const explicit = normalizeExplicitIsoTimestamp(value);
|
|
173
|
+
if (explicit)
|
|
174
|
+
return explicit;
|
|
175
|
+
return sourceVersion !== undefined
|
|
176
|
+
&& sourceVersion !== null
|
|
177
|
+
&& sourceVersion < CASE_SCHEMA_VERSION
|
|
178
|
+
? normalizeLegacyIsoTimestamp(value)
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
154
181
|
export function isoOrNow(value, fallback) {
|
|
155
182
|
return isoOrNull(value) || fallback;
|
|
156
183
|
}
|
|
@@ -194,12 +221,12 @@ export function normalizeNoteBody(body) {
|
|
|
194
221
|
* @param {string | null} fallback
|
|
195
222
|
* @returns {CaseNote | null}
|
|
196
223
|
*/
|
|
197
|
-
function normalizeNote(raw, fallback) {
|
|
224
|
+
function normalizeNote(raw, fallback, sourceVersion) {
|
|
198
225
|
const record = objectRecord(raw);
|
|
199
226
|
const body = normalizeNoteBody(record.body);
|
|
200
227
|
if (!body)
|
|
201
228
|
return null;
|
|
202
|
-
const createdAt =
|
|
229
|
+
const createdAt = caseTimestampOrNull(record.createdAt, sourceVersion) || fallback;
|
|
203
230
|
if (!createdAt)
|
|
204
231
|
return null;
|
|
205
232
|
return {
|
|
@@ -215,13 +242,13 @@ function normalizeNote(raw, fallback) {
|
|
|
215
242
|
* updatedAt for imports, never the current time)
|
|
216
243
|
* @returns {CaseNote[]}
|
|
217
244
|
*/
|
|
218
|
-
export function normalizeNotes(value, fallback) {
|
|
245
|
+
export function normalizeNotes(value, fallback, sourceVersion) {
|
|
219
246
|
if (!Array.isArray(value))
|
|
220
247
|
return [];
|
|
221
248
|
const seen = new Set();
|
|
222
249
|
const notes = [];
|
|
223
250
|
for (const raw of value) {
|
|
224
|
-
const note = normalizeNote(raw, fallback);
|
|
251
|
+
const note = normalizeNote(raw, fallback, sourceVersion);
|
|
225
252
|
if (!note || seen.has(note.id))
|
|
226
253
|
continue;
|
|
227
254
|
seen.add(note.id);
|