@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
|
|
2
|
+
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
+
import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
5
|
+
import { normalizeCaseStore } from "./case-migration-model.mjs";
|
|
6
|
+
import { serializeCaseStore } from "./case-storage-model.mjs";
|
|
7
|
+
/** Historical wire shape remains independent of current in-memory defaults. */
|
|
8
|
+
export function canonicalCaseExportProjection(cases, caseVersion) {
|
|
9
|
+
if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
|
|
10
|
+
return [...cases];
|
|
11
|
+
return cases.map((value) => {
|
|
12
|
+
if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
|
|
13
|
+
throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
|
|
14
|
+
}
|
|
15
|
+
const { title: _title, ...published } = value;
|
|
16
|
+
return {
|
|
17
|
+
...published,
|
|
18
|
+
sightings: [...value.sightings].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
|
|
19
|
+
|| (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Editing never silently repairs, drops or truncates an imported record. */
|
|
24
|
+
export function readEditableCaseExport(input) {
|
|
25
|
+
const root = parseBoundedJsonObject(input, {
|
|
26
|
+
label: 'Case file', maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES,
|
|
27
|
+
limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES),
|
|
28
|
+
});
|
|
29
|
+
if (Object.keys(root).some(key => !['version', 'exportedAt', 'cases'].includes(key))) {
|
|
30
|
+
throw new TypeError('Case editing requires an ordinary Case export, not a response packet or an envelope with additional fields.');
|
|
31
|
+
}
|
|
32
|
+
if (!CLI_CASE_PACK_INPUT_CASE_VERSIONS.some(version => version === root.version) || !Array.isArray(root.cases)) {
|
|
33
|
+
throw new TypeError(`Case editing reads exact schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')} and writes schema ${CASE_SCHEMA_VERSION}. Re-export other supported historical formats through the ordinary import workflow first.`);
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(root, 'exportedAt') && !normalizeExplicitIsoTimestamp(root.exportedAt)) {
|
|
36
|
+
throw new TypeError('The Case export time is invalid.');
|
|
37
|
+
}
|
|
38
|
+
const cases = normalizeCaseStore(root).cases;
|
|
39
|
+
const projected = canonicalCaseExportProjection(cases, root.version);
|
|
40
|
+
const byId = new Map(projected.map(record => [record.id, record]));
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
if (cases.length !== root.cases.length || root.cases.some(value => {
|
|
43
|
+
if (!value || typeof value !== 'object' || typeof value.id !== 'string' || seen.has(value.id))
|
|
44
|
+
return true;
|
|
45
|
+
seen.add(value.id);
|
|
46
|
+
return canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(byId.get(value.id));
|
|
47
|
+
}))
|
|
48
|
+
throw new TypeError('The Case file would lose, repair or truncate retained data during normalisation. Nothing was changed.');
|
|
49
|
+
if (new TextEncoder().encode(serializeCaseStore(cases)).byteLength > MAX_CASE_STORE_BYTES) {
|
|
50
|
+
throw new TypeError('The Case file exceeds the canonical store byte budget. Select fewer Cases; no retained evidence was pruned.');
|
|
51
|
+
}
|
|
52
|
+
return cases;
|
|
53
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, 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";
|
|
1
|
+
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeCaseObjective, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
|
|
2
|
+
import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, 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
3
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
4
|
+
import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
|
|
4
5
|
import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
|
|
5
6
|
import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
6
7
|
import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
|
|
7
|
-
import { buildCaseClosureLinkContext, mergeCaseActions,
|
|
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));
|
|
8
|
+
import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
|
|
11
9
|
export const MAX_CASE_INPUT_RECORDS = 2_000;
|
|
12
10
|
function compareCodeUnits(left, right) {
|
|
13
11
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
@@ -35,8 +33,8 @@ function assignUniqueIds(cases) {
|
|
|
35
33
|
}
|
|
36
34
|
/**
|
|
37
35
|
* Recovers a clean, bounded store from the current versioned envelope or an
|
|
38
|
-
* internal bare array, drops malformed records,
|
|
39
|
-
*
|
|
36
|
+
* internal bare array, drops malformed records, preserves distinct current
|
|
37
|
+
* Case identities, caps to MAX_CASES by recency,
|
|
40
38
|
* and guarantees globally unique safe ids. Retired, future, accessor-bearing,
|
|
41
39
|
* sparse, or otherwise non-JSON envelopes fail before normalisation.
|
|
42
40
|
* @param {unknown} raw
|
|
@@ -51,46 +49,64 @@ export function normalizeCaseStore(raw) {
|
|
|
51
49
|
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
50
|
}
|
|
53
51
|
if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
|
|
54
|
-
throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas
|
|
52
|
+
throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
55
53
|
}
|
|
56
54
|
if (sourceVersion > CASE_SCHEMA_VERSION) {
|
|
57
55
|
throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
58
56
|
}
|
|
59
|
-
if (sourceVersion
|
|
60
|
-
|
|
57
|
+
if (sourceVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION) {
|
|
58
|
+
assertModernCaseShape(raw, sourceVersion);
|
|
61
59
|
}
|
|
62
60
|
}
|
|
63
|
-
const
|
|
61
|
+
const byIdentity = new Map();
|
|
64
62
|
for (const item of boundedCaseList(raw).items) {
|
|
65
63
|
const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
|
|
66
64
|
if (!normalized)
|
|
67
65
|
continue;
|
|
68
|
-
|
|
66
|
+
// Published domain-centred stores retain their original recovery rule.
|
|
67
|
+
// Current incidents share a domain without sharing analyst decisions.
|
|
68
|
+
const key = sourceVersion !== null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION
|
|
69
|
+
? normalized.domain : `${normalized.domain}\u0000${normalized.id}`;
|
|
70
|
+
const existing = byIdentity.get(key);
|
|
69
71
|
if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
|
|
70
|
-
|
|
72
|
+
byIdentity.set(key, normalized);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
const cases = [...
|
|
75
|
+
const cases = [...byIdentity.values()]
|
|
74
76
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
75
77
|
.slice(0, MAX_CASES);
|
|
76
78
|
assignUniqueIds(cases);
|
|
77
79
|
return { version: CASE_SCHEMA_VERSION, cases };
|
|
78
80
|
}
|
|
79
|
-
function
|
|
81
|
+
function assertModernCaseShape(raw, sourceVersion) {
|
|
80
82
|
for (const item of boundedCaseList(raw).items) {
|
|
81
83
|
const itemRecord = objectRecord(item);
|
|
84
|
+
if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
|
|
85
|
+
throw new TypeError('Retained file references require the current Case schema; no data was changed.');
|
|
86
|
+
}
|
|
82
87
|
const evidenceHistory = itemRecord.evidenceHistory;
|
|
83
88
|
for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
|
|
84
89
|
const record = objectRecord(snapshot);
|
|
85
90
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
|
|
86
|
-
throw new TypeError(`Case schema ${
|
|
91
|
+
throw new TypeError(`Case schema ${sourceVersion} evidence snapshots must declare their exact submitted hostname, including null; no data was changed.`);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
const actions = itemRecord.actions;
|
|
90
95
|
for (const action of Array.isArray(actions) ? actions : []) {
|
|
91
96
|
const record = objectRecord(action);
|
|
92
97
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
|
|
93
|
-
throw new TypeError(`Case schema ${
|
|
98
|
+
throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route observation time, including null; no data was changed.`);
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
|
|
101
|
+
throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route review deadline, including null; no data was changed.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const decisions = itemRecord.decisions;
|
|
105
|
+
for (const decision of Array.isArray(decisions) ? decisions : []) {
|
|
106
|
+
const record = objectRecord(decision);
|
|
107
|
+
if (Object.keys(record).length > 0
|
|
108
|
+
&& (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
|
|
109
|
+
throw new TypeError(`Case schema ${sourceVersion} analyst decisions must declare confidence and its basis; no data was changed.`);
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
}
|
|
@@ -140,9 +156,6 @@ function boundedCaseList(raw) {
|
|
|
140
156
|
* @param {string} [nowIso]
|
|
141
157
|
* @returns {CaseRecord}
|
|
142
158
|
*/
|
|
143
|
-
function importScalar(value, valid) {
|
|
144
|
-
return typeof value === 'string' && valid.has(value) ? value : undefined;
|
|
145
|
-
}
|
|
146
159
|
/**
|
|
147
160
|
* Validates one imported record into a patch. Unlike normalizeCase, absent or
|
|
148
161
|
* invalid scalar fields stay `undefined` (never defaulted) and a missing/invalid
|
|
@@ -163,7 +176,7 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
163
176
|
|| null;
|
|
164
177
|
const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
|
|
165
178
|
const timestampOptions = {
|
|
166
|
-
legacyTimestamps: importedVersion <
|
|
179
|
+
legacyTimestamps: importedVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
|
|
167
180
|
sourceVersion: importedVersion,
|
|
168
181
|
};
|
|
169
182
|
const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
@@ -174,6 +187,8 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
174
187
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
175
188
|
const actions = normalizeCaseActions(record.actions, normalizedFallback, { ...timestampOptions, validEvidencePinIds: pinIds });
|
|
176
189
|
const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds, timestampOptions);
|
|
190
|
+
const decisions = normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds, timestampOptions);
|
|
191
|
+
const manualTrail = normalizeCaseManualTrail(record.manualTrail, normalizedFallback, timestampOptions);
|
|
177
192
|
const sightings = normalizeCaseSightings(record.sightings, normalizedFallback, pinIds, timestampOptions);
|
|
178
193
|
const observedEffects = normalizeCaseObservedEffectHistory(importedVersion >= 13 ? record.observedEffects : undefined, normalizedFallback, pinIds, new Set(sightings.map((item) => item.id)), timestampOptions);
|
|
179
194
|
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));
|
|
@@ -181,17 +196,27 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
181
196
|
const brandProfileReferences = importedVersion >= 12
|
|
182
197
|
? inspectCaseBrandProfileIds(record.brandProfileIds)
|
|
183
198
|
: { ids: [], omitted: 0 };
|
|
199
|
+
const authoredHistoryOmitted = [
|
|
200
|
+
[record.evidencePins, evidencePins],
|
|
201
|
+
[record.decisions, decisions],
|
|
202
|
+
[record.assertions, assertions],
|
|
203
|
+
[record.manualTrail, manualTrail],
|
|
204
|
+
[record.sightings, sightings],
|
|
205
|
+
].reduce((total, [candidates, retained]) => total
|
|
206
|
+
+ Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
|
|
184
207
|
return {
|
|
185
208
|
domain,
|
|
186
209
|
rawId: typeof record.id === 'string' ? record.id : null,
|
|
187
|
-
|
|
188
|
-
|
|
210
|
+
title: typeof record.title === 'string' && importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? normalizeCaseObjective(record.title) : undefined,
|
|
211
|
+
status: isValidStatus(record.status) ? record.status : undefined,
|
|
212
|
+
disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
|
|
189
213
|
reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
|
|
190
214
|
? normalizeReviewReasonCode(record.reviewReasonCode)
|
|
191
215
|
: undefined,
|
|
192
216
|
brandProfileIds: brandProfileReferences.ids,
|
|
193
217
|
brandProfileReferencesOmitted: brandProfileReferences.omitted,
|
|
194
|
-
|
|
218
|
+
authoredHistoryOmitted,
|
|
219
|
+
source: isValidSource(record.source) ? record.source : undefined,
|
|
195
220
|
evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
|
|
196
221
|
source: 'import',
|
|
197
222
|
fallback: importFallback,
|
|
@@ -199,16 +224,17 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
199
224
|
caseDomain: domain,
|
|
200
225
|
}),
|
|
201
226
|
evidencePins,
|
|
202
|
-
decisions
|
|
227
|
+
decisions,
|
|
203
228
|
actions,
|
|
204
229
|
assertions,
|
|
205
|
-
manualTrail
|
|
230
|
+
manualTrail,
|
|
206
231
|
sightings,
|
|
207
232
|
observedEffects,
|
|
208
233
|
closures,
|
|
209
234
|
branches: importedVersion >= 11
|
|
210
235
|
? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
|
|
211
236
|
: [],
|
|
237
|
+
attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
|
|
212
238
|
tags: normalizeTags(record.tags),
|
|
213
239
|
// Imported notes fall back only to the imported record's own timestamps
|
|
214
240
|
// (never "now"), so a timestamp-less note gets a stable, deterministic time
|
|
@@ -229,6 +255,22 @@ function unionNotes(a, b) {
|
|
|
229
255
|
const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
|
230
256
|
return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
|
|
231
257
|
}
|
|
258
|
+
function retainLocalAuthoredRecords(local, imported, maximum) {
|
|
259
|
+
const records = [...local];
|
|
260
|
+
const retainedIds = new Set(records.map((item) => item.id));
|
|
261
|
+
let omitted = 0;
|
|
262
|
+
for (const item of imported) {
|
|
263
|
+
if (retainedIds.has(item.id))
|
|
264
|
+
continue;
|
|
265
|
+
if (records.length >= maximum) {
|
|
266
|
+
omitted += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
records.push(item);
|
|
270
|
+
retainedIds.add(item.id);
|
|
271
|
+
}
|
|
272
|
+
return { records, omitted };
|
|
273
|
+
}
|
|
232
274
|
// Additive, deduplicated union of two evidence histories. Identical material
|
|
233
275
|
// collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
|
|
234
276
|
// are retained subject to the per-case bound, and an older import can never
|
|
@@ -241,6 +283,7 @@ function caseFromPatch(patch, now) {
|
|
|
241
283
|
return {
|
|
242
284
|
id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
|
|
243
285
|
domain: patch.domain,
|
|
286
|
+
title: patch.title ?? '',
|
|
244
287
|
status: patch.status ?? DEFAULT_STATUS,
|
|
245
288
|
disposition: patch.disposition ?? DEFAULT_DISPOSITION,
|
|
246
289
|
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
@@ -258,6 +301,7 @@ function caseFromPatch(patch, now) {
|
|
|
258
301
|
observedEffects: patch.observedEffects,
|
|
259
302
|
closures: patch.closures,
|
|
260
303
|
branches: patch.branches,
|
|
304
|
+
...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
|
|
261
305
|
createdAt: patch.createdAt || patch.updatedAt || now,
|
|
262
306
|
updatedAt: patch.updatedAt || patch.createdAt || now,
|
|
263
307
|
};
|
|
@@ -274,18 +318,33 @@ function caseFromPatch(patch, now) {
|
|
|
274
318
|
function applyImportPatch(local, patch) {
|
|
275
319
|
const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
|
|
276
320
|
const fallback = patch.updatedAt || local.updatedAt;
|
|
277
|
-
const
|
|
321
|
+
const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
|
|
322
|
+
const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
|
|
278
323
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
279
324
|
const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
|
|
280
|
-
const
|
|
325
|
+
const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
|
|
326
|
+
const assertions = normalizeCaseAssertions(assertionSelection.records, fallback, pinIds);
|
|
281
327
|
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
282
|
-
const
|
|
328
|
+
const sightingSelection = retainLocalAuthoredRecords(local.sightings, patch.sightings, MAX_CASE_SIGHTINGS);
|
|
329
|
+
const sightings = normalizeCaseSightings(sightingSelection.records, fallback, pinIds);
|
|
283
330
|
const sightingIds = new Set(sightings.map((item) => item.id));
|
|
284
331
|
const observedEffects = mergeCaseObservedEffectHistories(local.observedEffects, patch.observedEffects, fallback, pinIds, sightingIds);
|
|
285
332
|
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));
|
|
286
333
|
const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
|
|
334
|
+
const decisionSelection = retainLocalAuthoredRecords(local.decisions, patch.decisions, MAX_CASE_DECISIONS);
|
|
335
|
+
const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
|
|
336
|
+
const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
|
|
337
|
+
const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
|
|
338
|
+
const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
|
|
339
|
+
const authoredHistoryOmitted = patch.authoredHistoryOmitted
|
|
340
|
+
+ pinSelection.omitted
|
|
341
|
+
+ decisionSelection.omitted
|
|
342
|
+
+ assertionSelection.omitted
|
|
343
|
+
+ trailSelection.omitted
|
|
344
|
+
+ sightingSelection.omitted;
|
|
287
345
|
return { record: {
|
|
288
346
|
...local,
|
|
347
|
+
title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
|
|
289
348
|
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
290
349
|
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
291
350
|
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
@@ -293,19 +352,20 @@ function applyImportPatch(local, patch) {
|
|
|
293
352
|
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
294
353
|
evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
|
|
295
354
|
evidencePins,
|
|
296
|
-
decisions
|
|
355
|
+
decisions,
|
|
297
356
|
actions,
|
|
298
357
|
assertions,
|
|
299
|
-
manualTrail
|
|
358
|
+
manualTrail,
|
|
300
359
|
sightings,
|
|
301
360
|
observedEffects,
|
|
302
361
|
closures,
|
|
303
362
|
branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
|
|
363
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
304
364
|
tags: normalizeTags([...local.tags, ...patch.tags]),
|
|
305
365
|
notes: unionNotes(local.notes, patch.notes),
|
|
306
366
|
createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
|
|
307
367
|
updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
|
|
308
|
-
}, brandProfileReferencesOmitted: brandProfileReferences.omitted };
|
|
368
|
+
}, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
|
|
309
369
|
}
|
|
310
370
|
function pickFreeId(preferred, domain, used) {
|
|
311
371
|
const wanted = safeId(preferred);
|
|
@@ -411,14 +471,14 @@ function caseCollectionImportEnvelope(importedRaw) {
|
|
|
411
471
|
}
|
|
412
472
|
/**
|
|
413
473
|
* Merges an imported (already parsed) value into the local cases. Predictable
|
|
414
|
-
* and idempotent: unknown records are skipped,
|
|
474
|
+
* and idempotent: unknown records are skipped, matching Case identities merge without
|
|
415
475
|
* losing newer local decisions, and new ones are added until the store bound is
|
|
416
476
|
* reached. An imported envelope that declares a schema version newer than we
|
|
417
477
|
* support is rejected up front (before any local data is touched) rather than
|
|
418
478
|
* reinterpreted.
|
|
419
479
|
* @param {CaseRecord[]} localCases
|
|
420
480
|
* @param {unknown} importedRaw
|
|
421
|
-
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
|
|
481
|
+
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
|
|
422
482
|
*/
|
|
423
483
|
export function mergeCases(localCases, importedRaw) {
|
|
424
484
|
assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
|
|
@@ -426,7 +486,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
426
486
|
const importedVersion = parseStoreVersion(importedEnvelope);
|
|
427
487
|
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
|
|
428
488
|
&& !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
|
|
429
|
-
throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas
|
|
489
|
+
throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas ${CASE_IMPORT_VERSIONS.filter((version) => version < CASE_SCHEMA_VERSION).join(', ')} migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
430
490
|
}
|
|
431
491
|
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
|
|
432
492
|
throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
@@ -435,37 +495,59 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
435
495
|
|| !Array.isArray(importedEnvelope.cases)) {
|
|
436
496
|
throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
437
497
|
}
|
|
438
|
-
if (importedVersion
|
|
439
|
-
|
|
498
|
+
if (importedVersion !== null && importedVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
|
|
499
|
+
assertModernCaseShape(importedEnvelope, importedVersion);
|
|
440
500
|
const local = normalizeCaseStore(localCases).cases;
|
|
441
501
|
const supportedImportedVersion = importedVersion ?? 0;
|
|
442
|
-
const
|
|
502
|
+
const byId = new Map(local.map((item) => [item.id, item]));
|
|
443
503
|
const usedIds = new Set(local.map((item) => item.id));
|
|
444
504
|
let added = 0;
|
|
445
505
|
let updated = 0;
|
|
446
506
|
const imported = boundedCaseList(importedEnvelope);
|
|
447
507
|
let skipped = imported.omitted;
|
|
448
508
|
let brandProfileReferencesOmitted = 0;
|
|
509
|
+
let authoredHistoryOmitted = 0;
|
|
449
510
|
const fallback = new Date(0).toISOString();
|
|
511
|
+
const importedIds = new Set();
|
|
450
512
|
for (const item of imported.items) {
|
|
451
513
|
const patch = extractImportPatch(item, supportedImportedVersion);
|
|
452
514
|
if (!patch) {
|
|
453
515
|
skipped += 1;
|
|
454
516
|
continue;
|
|
455
517
|
}
|
|
456
|
-
const
|
|
518
|
+
const importedId = safeId(patch.rawId);
|
|
519
|
+
if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION && importedId) {
|
|
520
|
+
if (importedIds.has(importedId))
|
|
521
|
+
throw new Error('Incident Case imports cannot repeat a declared Case ID. No data was changed.');
|
|
522
|
+
importedIds.add(importedId);
|
|
523
|
+
}
|
|
524
|
+
let existing = importedId ? byId.get(importedId) : undefined;
|
|
525
|
+
if (existing && existing.domain !== patch.domain) {
|
|
526
|
+
if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION)
|
|
527
|
+
throw new Error('An imported Case ID belongs to a different domain in this workspace. No data was changed.');
|
|
528
|
+
existing = undefined;
|
|
529
|
+
}
|
|
530
|
+
if (!existing && (supportedImportedVersion < INCIDENT_CASE_SCHEMA_VERSION || !importedId)) {
|
|
531
|
+
const matchingDomain = [...byId.values()].filter(record => record.domain === patch.domain);
|
|
532
|
+
if (matchingDomain.length > 1) {
|
|
533
|
+
throw new Error(`The imported record for ${patch.domain} has no matching Case identity and several incidents exist. Import it into a separate workspace to review it without merging incidents; no data was changed.`);
|
|
534
|
+
}
|
|
535
|
+
existing = matchingDomain[0];
|
|
536
|
+
}
|
|
457
537
|
if (existing) {
|
|
458
538
|
const merged = applyImportPatch(existing, patch);
|
|
459
|
-
|
|
539
|
+
byId.set(existing.id, merged.record);
|
|
460
540
|
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
|
|
541
|
+
authoredHistoryOmitted += merged.authoredHistoryOmitted;
|
|
461
542
|
updated += 1;
|
|
462
543
|
}
|
|
463
|
-
else if (
|
|
544
|
+
else if (byId.size < MAX_CASES) {
|
|
464
545
|
const record = caseFromPatch(patch, fallback);
|
|
465
546
|
record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
|
|
466
547
|
usedIds.add(record.id);
|
|
467
|
-
|
|
548
|
+
byId.set(record.id, record);
|
|
468
549
|
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
|
|
550
|
+
authoredHistoryOmitted += patch.authoredHistoryOmitted;
|
|
469
551
|
added += 1;
|
|
470
552
|
}
|
|
471
553
|
else {
|
|
@@ -473,10 +555,11 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
473
555
|
}
|
|
474
556
|
}
|
|
475
557
|
return {
|
|
476
|
-
cases: normalizeCaseStore([...
|
|
558
|
+
cases: normalizeCaseStore([...byId.values()]).cases,
|
|
477
559
|
added,
|
|
478
560
|
updated,
|
|
479
561
|
skipped,
|
|
480
562
|
brandProfileReferencesOmitted,
|
|
563
|
+
authoredHistoryOmitted,
|
|
481
564
|
};
|
|
482
565
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isValidAsciiHostname } from "../../lib/hostname.mjs";
|
|
2
|
+
import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
|
|
3
|
+
import { enumeration, exact, text } from "../evidence/artifact-structure.mjs";
|
|
4
|
+
export const CASE_RECHECK_CONDITIONS = Object.freeze({ unknown: 'Not established', comparable: 'Comparable for this question', different: 'Different conditions' });
|
|
5
|
+
function reference(value, label) {
|
|
6
|
+
const id = text(value, label, 64);
|
|
7
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(id))
|
|
8
|
+
throw new TypeError(`${label} is invalid.`);
|
|
9
|
+
return id;
|
|
10
|
+
}
|
|
11
|
+
function contextFields(item) {
|
|
12
|
+
const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
|
|
13
|
+
if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
|
|
14
|
+
throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
|
|
15
|
+
}
|
|
16
|
+
const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
17
|
+
if (!conditions.trim())
|
|
18
|
+
throw new TypeError('Describe the conditions needed to compare the observations.');
|
|
19
|
+
return Object.freeze({ targetHostname, conditions,
|
|
20
|
+
baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin') });
|
|
21
|
+
}
|
|
22
|
+
/** Optional current-format context; historical records have no invented question. */
|
|
23
|
+
export function readCaseRecheckContext(value, sourceVersion) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
27
|
+
throw new TypeError('This Case format cannot contain structured recheck questions.');
|
|
28
|
+
return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
|
|
29
|
+
}
|
|
30
|
+
export function readCaseRecheckAnswerContext(value, sourceVersion) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
|
|
34
|
+
throw new TypeError('This Case format cannot contain structured recheck answers.');
|
|
35
|
+
const item = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
|
|
36
|
+
const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
|
|
37
|
+
if (!question.trim())
|
|
38
|
+
throw new TypeError('A recheck answer requires its original question.');
|
|
39
|
+
return Object.freeze({ ...contextFields(item), questionId: reference(item.questionId, 'Recheck question ID'),
|
|
40
|
+
question,
|
|
41
|
+
conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison') });
|
|
42
|
+
}
|
|
43
|
+
export function caseRecheckQuestions(assertions) {
|
|
44
|
+
return assertions.filter(item => item.kind === 'next_step' && item.recheck && item.state === 'open');
|
|
45
|
+
}
|
|
46
|
+
export function caseRecheckAnswerContext(question, conditionsMatch) {
|
|
47
|
+
if (question.kind !== 'next_step' || !question.recheck)
|
|
48
|
+
throw new TypeError('Select a saved recheck question.');
|
|
49
|
+
return readCaseRecheckAnswerContext({ ...question.recheck, questionId: question.id, question: question.statement, conditionsMatch });
|
|
50
|
+
}
|
|
51
|
+
/** A saved answer is a snapshot, not a live reference to an editable plan. */
|
|
52
|
+
export function assertCurrentRecheckQuestion(answer, assertions) {
|
|
53
|
+
const question = caseRecheckQuestions(assertions).find(item => item.id === answer.questionId);
|
|
54
|
+
if (!question || JSON.stringify(caseRecheckAnswerContext(question, answer.conditionsMatch)) !== JSON.stringify(answer)) {
|
|
55
|
+
throw new Error('The recheck question changed or was resolved. Refresh and review its current conditions before saving this answer.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function caseRecheckComparisonWarnings(context, pins, current) {
|
|
59
|
+
const warnings = [];
|
|
60
|
+
if (context.conditionsMatch !== 'comparable')
|
|
61
|
+
warnings.push(context.conditionsMatch === 'different'
|
|
62
|
+
? 'The comparison conditions differ.' : 'Comparable conditions have not been confirmed.');
|
|
63
|
+
const baseline = pins.find(pin => pin.id === context.baselinePinId);
|
|
64
|
+
if (context.baselinePinId && !baseline)
|
|
65
|
+
warnings.push('The baseline evidence is no longer available.');
|
|
66
|
+
if (current) {
|
|
67
|
+
if (current.observationHostname !== context.targetHostname)
|
|
68
|
+
warnings.push('The current evidence concerns a different or unknown hostname.');
|
|
69
|
+
if (current.completeness !== 'complete' || current.truncated || current.sourceState !== 'complete' && current.sourceState !== 'success' && current.sourceState !== 'reviewed' && current.sourceState !== 'not_found')
|
|
70
|
+
warnings.push('The current source does not establish a complete observation.');
|
|
71
|
+
if (baseline) {
|
|
72
|
+
if (baseline.id === current.id || !baseline.observedAt || !current.observedAt || Date.parse(current.observedAt) <= Date.parse(baseline.observedAt))
|
|
73
|
+
warnings.push('A later source observation is needed; reviewing the baseline again is not a recheck.');
|
|
74
|
+
if (baseline.observationHostname && baseline.observationHostname !== context.targetHostname)
|
|
75
|
+
warnings.push('The baseline concerns a different hostname.');
|
|
76
|
+
if (baseline.field && current.field !== baseline.field || baseline.source !== current.source)
|
|
77
|
+
warnings.push('The observations use different fields or sources.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return warnings;
|
|
81
|
+
}
|
|
82
|
+
export function assertRecheckNonReproduction(state, context, completeness, pins, current, observedAt) {
|
|
83
|
+
if (state !== 'not_reproduced')
|
|
84
|
+
return;
|
|
85
|
+
const warnings = caseRecheckComparisonWarnings(context, pins, current);
|
|
86
|
+
const baseline = pins.find(pin => pin.id === context.baselinePinId);
|
|
87
|
+
if (!current && baseline && (!baseline.observedAt || !observedAt || !Number.isFinite(Date.parse(observedAt)) || Date.parse(observedAt) <= Date.parse(baseline.observedAt)))
|
|
88
|
+
warnings.push('A later independent observation is needed.');
|
|
89
|
+
if (completeness !== 'complete' || warnings.length)
|
|
90
|
+
throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
|
|
91
|
+
}
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
|
|
2
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
|
+
import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
|
|
3
4
|
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, };
|
|
4
|
-
export
|
|
5
|
-
{ value: 'new', label: 'New' },
|
|
6
|
-
{ value: 'reviewing', label: 'Reviewing' },
|
|
7
|
-
{ value: 'monitoring', label: 'Monitoring' },
|
|
8
|
-
{ value: 'escalated', label: 'Escalated' },
|
|
9
|
-
{ value: 'resolved', label: 'Resolved' },
|
|
10
|
-
];
|
|
11
|
-
export const CASE_DISPOSITIONS = [
|
|
12
|
-
{ value: 'unreviewed', label: 'Unreviewed' },
|
|
13
|
-
{ value: 'suspicious', label: 'Suspicious' },
|
|
14
|
-
{ value: 'confirmed_abuse', label: 'Confirmed abuse' },
|
|
15
|
-
{ value: 'false_positive', label: 'False positive' },
|
|
16
|
-
{ value: 'expected', label: 'Expected' },
|
|
17
|
-
{ value: 'closed_no_action', label: 'Closed without action' },
|
|
18
|
-
];
|
|
5
|
+
export { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, };
|
|
19
6
|
export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
|
|
20
|
-
export const CASE_SOURCES = [
|
|
7
|
+
export const CASE_SOURCES = Object.freeze([
|
|
21
8
|
{ value: 'lookup', label: 'Lookup' },
|
|
22
9
|
{ value: 'bulk', label: 'Bulk' },
|
|
23
10
|
{ value: 'monitor', label: 'Monitor' },
|
|
24
11
|
{ value: 'manual', label: 'Manual' },
|
|
25
12
|
{ value: 'unknown', label: 'Unknown' },
|
|
26
|
-
];
|
|
27
|
-
export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
|
|
28
|
-
export const DEFAULT_STATUS = 'new';
|
|
29
|
-
export const DEFAULT_DISPOSITION = 'unreviewed';
|
|
13
|
+
]);
|
|
14
|
+
export const EVIDENCE_SOURCES = Object.freeze(['lookup', 'bulk', 'monitor', 'import', 'unknown']);
|
|
30
15
|
export const DEFAULT_SOURCE = 'unknown';
|