@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
|
|
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";
|
|
@@ -32,8 +33,8 @@ function assignUniqueIds(cases) {
|
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* Recovers a clean, bounded store from the current versioned envelope or an
|
|
35
|
-
* internal bare array, drops malformed records,
|
|
36
|
-
*
|
|
36
|
+
* internal bare array, drops malformed records, preserves distinct current
|
|
37
|
+
* Case identities, caps to MAX_CASES by recency,
|
|
37
38
|
* and guarantees globally unique safe ids. Retired, future, accessor-bearing,
|
|
38
39
|
* sparse, or otherwise non-JSON envelopes fail before normalisation.
|
|
39
40
|
* @param {unknown} raw
|
|
@@ -53,44 +54,51 @@ export function normalizeCaseStore(raw) {
|
|
|
53
54
|
if (sourceVersion > CASE_SCHEMA_VERSION) {
|
|
54
55
|
throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
55
56
|
}
|
|
56
|
-
if (sourceVersion
|
|
57
|
-
|
|
57
|
+
if (sourceVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION) {
|
|
58
|
+
assertModernCaseShape(raw, sourceVersion);
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
const
|
|
61
|
+
const byIdentity = new Map();
|
|
61
62
|
for (const item of boundedCaseList(raw).items) {
|
|
62
63
|
const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
|
|
63
64
|
if (!normalized)
|
|
64
65
|
continue;
|
|
65
|
-
|
|
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);
|
|
66
71
|
if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
|
|
67
|
-
|
|
72
|
+
byIdentity.set(key, normalized);
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
|
-
const cases = [...
|
|
75
|
+
const cases = [...byIdentity.values()]
|
|
71
76
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
72
77
|
.slice(0, MAX_CASES);
|
|
73
78
|
assignUniqueIds(cases);
|
|
74
79
|
return { version: CASE_SCHEMA_VERSION, cases };
|
|
75
80
|
}
|
|
76
|
-
function
|
|
81
|
+
function assertModernCaseShape(raw, sourceVersion) {
|
|
77
82
|
for (const item of boundedCaseList(raw).items) {
|
|
78
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
|
+
}
|
|
79
87
|
const evidenceHistory = itemRecord.evidenceHistory;
|
|
80
88
|
for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
|
|
81
89
|
const record = objectRecord(snapshot);
|
|
82
90
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
|
|
83
|
-
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.`);
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
const actions = itemRecord.actions;
|
|
87
95
|
for (const action of Array.isArray(actions) ? actions : []) {
|
|
88
96
|
const record = objectRecord(action);
|
|
89
97
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
|
|
90
|
-
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.`);
|
|
91
99
|
}
|
|
92
100
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
|
|
93
|
-
throw new TypeError(`Case schema ${
|
|
101
|
+
throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route review deadline, including null; no data was changed.`);
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
const decisions = itemRecord.decisions;
|
|
@@ -98,7 +106,7 @@ function assertCurrentCaseShape(raw) {
|
|
|
98
106
|
const record = objectRecord(decision);
|
|
99
107
|
if (Object.keys(record).length > 0
|
|
100
108
|
&& (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
|
|
101
|
-
throw new TypeError(`Case schema ${
|
|
109
|
+
throw new TypeError(`Case schema ${sourceVersion} analyst decisions must declare confidence and its basis; no data was changed.`);
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
}
|
|
@@ -168,7 +176,7 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
168
176
|
|| null;
|
|
169
177
|
const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
|
|
170
178
|
const timestampOptions = {
|
|
171
|
-
legacyTimestamps: importedVersion <
|
|
179
|
+
legacyTimestamps: importedVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
|
|
172
180
|
sourceVersion: importedVersion,
|
|
173
181
|
};
|
|
174
182
|
const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
@@ -199,6 +207,7 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
199
207
|
return {
|
|
200
208
|
domain,
|
|
201
209
|
rawId: typeof record.id === 'string' ? record.id : null,
|
|
210
|
+
title: typeof record.title === 'string' && importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? normalizeCaseObjective(record.title) : undefined,
|
|
202
211
|
status: isValidStatus(record.status) ? record.status : undefined,
|
|
203
212
|
disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
|
|
204
213
|
reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
|
|
@@ -225,6 +234,7 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
225
234
|
branches: importedVersion >= 11
|
|
226
235
|
? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
|
|
227
236
|
: [],
|
|
237
|
+
attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
|
|
228
238
|
tags: normalizeTags(record.tags),
|
|
229
239
|
// Imported notes fall back only to the imported record's own timestamps
|
|
230
240
|
// (never "now"), so a timestamp-less note gets a stable, deterministic time
|
|
@@ -273,6 +283,7 @@ function caseFromPatch(patch, now) {
|
|
|
273
283
|
return {
|
|
274
284
|
id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
|
|
275
285
|
domain: patch.domain,
|
|
286
|
+
title: patch.title ?? '',
|
|
276
287
|
status: patch.status ?? DEFAULT_STATUS,
|
|
277
288
|
disposition: patch.disposition ?? DEFAULT_DISPOSITION,
|
|
278
289
|
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
@@ -290,6 +301,7 @@ function caseFromPatch(patch, now) {
|
|
|
290
301
|
observedEffects: patch.observedEffects,
|
|
291
302
|
closures: patch.closures,
|
|
292
303
|
branches: patch.branches,
|
|
304
|
+
...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
|
|
293
305
|
createdAt: patch.createdAt || patch.updatedAt || now,
|
|
294
306
|
updatedAt: patch.updatedAt || patch.createdAt || now,
|
|
295
307
|
};
|
|
@@ -323,6 +335,7 @@ function applyImportPatch(local, patch) {
|
|
|
323
335
|
const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
|
|
324
336
|
const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
|
|
325
337
|
const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
|
|
338
|
+
const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
|
|
326
339
|
const authoredHistoryOmitted = patch.authoredHistoryOmitted
|
|
327
340
|
+ pinSelection.omitted
|
|
328
341
|
+ decisionSelection.omitted
|
|
@@ -331,6 +344,7 @@ function applyImportPatch(local, patch) {
|
|
|
331
344
|
+ sightingSelection.omitted;
|
|
332
345
|
return { record: {
|
|
333
346
|
...local,
|
|
347
|
+
title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
|
|
334
348
|
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
335
349
|
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
336
350
|
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
@@ -346,6 +360,7 @@ function applyImportPatch(local, patch) {
|
|
|
346
360
|
observedEffects,
|
|
347
361
|
closures,
|
|
348
362
|
branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
|
|
363
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
349
364
|
tags: normalizeTags([...local.tags, ...patch.tags]),
|
|
350
365
|
notes: unionNotes(local.notes, patch.notes),
|
|
351
366
|
createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
|
|
@@ -456,7 +471,7 @@ function caseCollectionImportEnvelope(importedRaw) {
|
|
|
456
471
|
}
|
|
457
472
|
/**
|
|
458
473
|
* Merges an imported (already parsed) value into the local cases. Predictable
|
|
459
|
-
* and idempotent: unknown records are skipped,
|
|
474
|
+
* and idempotent: unknown records are skipped, matching Case identities merge without
|
|
460
475
|
* losing newer local decisions, and new ones are added until the store bound is
|
|
461
476
|
* reached. An imported envelope that declares a schema version newer than we
|
|
462
477
|
* support is rejected up front (before any local data is touched) rather than
|
|
@@ -480,11 +495,11 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
480
495
|
|| !Array.isArray(importedEnvelope.cases)) {
|
|
481
496
|
throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
482
497
|
}
|
|
483
|
-
if (importedVersion
|
|
484
|
-
|
|
498
|
+
if (importedVersion !== null && importedVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
|
|
499
|
+
assertModernCaseShape(importedEnvelope, importedVersion);
|
|
485
500
|
const local = normalizeCaseStore(localCases).cases;
|
|
486
501
|
const supportedImportedVersion = importedVersion ?? 0;
|
|
487
|
-
const
|
|
502
|
+
const byId = new Map(local.map((item) => [item.id, item]));
|
|
488
503
|
const usedIds = new Set(local.map((item) => item.id));
|
|
489
504
|
let added = 0;
|
|
490
505
|
let updated = 0;
|
|
@@ -493,25 +508,44 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
493
508
|
let brandProfileReferencesOmitted = 0;
|
|
494
509
|
let authoredHistoryOmitted = 0;
|
|
495
510
|
const fallback = new Date(0).toISOString();
|
|
511
|
+
const importedIds = new Set();
|
|
496
512
|
for (const item of imported.items) {
|
|
497
513
|
const patch = extractImportPatch(item, supportedImportedVersion);
|
|
498
514
|
if (!patch) {
|
|
499
515
|
skipped += 1;
|
|
500
516
|
continue;
|
|
501
517
|
}
|
|
502
|
-
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
|
+
}
|
|
503
537
|
if (existing) {
|
|
504
538
|
const merged = applyImportPatch(existing, patch);
|
|
505
|
-
|
|
539
|
+
byId.set(existing.id, merged.record);
|
|
506
540
|
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
|
|
507
541
|
authoredHistoryOmitted += merged.authoredHistoryOmitted;
|
|
508
542
|
updated += 1;
|
|
509
543
|
}
|
|
510
|
-
else if (
|
|
544
|
+
else if (byId.size < MAX_CASES) {
|
|
511
545
|
const record = caseFromPatch(patch, fallback);
|
|
512
546
|
record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
|
|
513
547
|
usedIds.add(record.id);
|
|
514
|
-
|
|
548
|
+
byId.set(record.id, record);
|
|
515
549
|
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
|
|
516
550
|
authoredHistoryOmitted += patch.authoredHistoryOmitted;
|
|
517
551
|
added += 1;
|
|
@@ -521,7 +555,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
521
555
|
}
|
|
522
556
|
}
|
|
523
557
|
return {
|
|
524
|
-
cases: normalizeCaseStore([...
|
|
558
|
+
cases: normalizeCaseStore([...byId.values()]).cases,
|
|
525
559
|
added,
|
|
526
560
|
updated,
|
|
527
561
|
skipped,
|
|
@@ -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,8 +1,9 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import {
|
|
3
|
+
import { CASE_REVIEW_REASONS, CASE_SOURCES, 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
4
|
import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
|
|
5
5
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
|
+
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
6
7
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
7
8
|
// Forward-version policy (two distinct guarantees):
|
|
8
9
|
// - A locally-stored envelope that declares a version greater than this is
|
|
@@ -163,7 +164,7 @@ export function caseTimestampOrNull(value, sourceVersion) {
|
|
|
163
164
|
return explicit;
|
|
164
165
|
return sourceVersion !== undefined
|
|
165
166
|
&& sourceVersion !== null
|
|
166
|
-
&& sourceVersion <
|
|
167
|
+
&& sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION
|
|
167
168
|
? normalizeLegacyIsoTimestamp(value)
|
|
168
169
|
: null;
|
|
169
170
|
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
4
|
+
import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
|
|
5
|
+
import { selectExistingCase } from "./case-selection.mjs";
|
|
4
6
|
import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
|
|
5
|
-
import {
|
|
7
|
+
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
8
|
+
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
|
|
9
|
+
import { readCaseAttachments } from "./case-attachment-model.mjs";
|
|
10
|
+
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
6
11
|
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
7
12
|
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
8
13
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
9
14
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
10
15
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
11
16
|
export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
|
|
12
|
-
export
|
|
17
|
+
export { MAX_CASE_OBJECTIVE_LENGTH };
|
|
13
18
|
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
14
19
|
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
15
20
|
const OBJECTIVE_PREFIX = 'Objective: ';
|
|
@@ -23,18 +28,8 @@ export function normalizeCaseObjective(value) {
|
|
|
23
28
|
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
24
29
|
}
|
|
25
30
|
export function parseIncidentUrlContext(value) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
|
|
29
|
-
return null;
|
|
30
|
-
let parsed;
|
|
31
|
-
try {
|
|
32
|
-
parsed = new URL(value);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
31
|
+
const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
|
|
32
|
+
if (!parsed)
|
|
38
33
|
return null;
|
|
39
34
|
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
40
35
|
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
@@ -142,7 +137,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
142
137
|
const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
|
|
143
138
|
const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
|
|
144
139
|
const timestampOptions = {
|
|
145
|
-
legacyTimestamps: sourceVersion != null && sourceVersion <
|
|
140
|
+
legacyTimestamps: sourceVersion != null && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
|
|
146
141
|
...(sourceVersion === undefined ? {} : { sourceVersion }),
|
|
147
142
|
};
|
|
148
143
|
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
|
|
@@ -155,9 +150,11 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
155
150
|
const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
|
|
156
151
|
const normalizedStatus = normalizeStatus(record.status);
|
|
157
152
|
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
153
|
+
const attachments = readCaseAttachments(record.attachments);
|
|
158
154
|
return {
|
|
159
155
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
160
156
|
domain,
|
|
157
|
+
title: sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION ? '' : normalizeCaseObjective(record.title),
|
|
161
158
|
status: caseStatusRequiresClosure(normalizedStatus)
|
|
162
159
|
&& closures.records.length === 0 && !closures.preV13HistoryUnavailable
|
|
163
160
|
? 'reviewing'
|
|
@@ -178,6 +175,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
178
175
|
observedEffects,
|
|
179
176
|
closures,
|
|
180
177
|
branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
|
|
178
|
+
...(attachments === undefined ? {} : { attachments }),
|
|
181
179
|
createdAt,
|
|
182
180
|
updatedAt,
|
|
183
181
|
};
|
|
@@ -213,6 +211,12 @@ export function createCase(input, nowIso) {
|
|
|
213
211
|
const observedEffects = input.observedEffectReview !== undefined
|
|
214
212
|
? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
|
|
215
213
|
: normalizeCaseObservedEffectHistory(undefined, now);
|
|
214
|
+
for (const review of observedEffects.reviews) {
|
|
215
|
+
if (!review.recheck)
|
|
216
|
+
continue;
|
|
217
|
+
assertCurrentRecheckQuestion(review.recheck, assertions);
|
|
218
|
+
assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
|
|
219
|
+
}
|
|
216
220
|
const closures = input.closure !== undefined
|
|
217
221
|
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
218
222
|
: normalizeCaseClosureHistory(undefined, now);
|
|
@@ -222,6 +226,7 @@ export function createCase(input, nowIso) {
|
|
|
222
226
|
return {
|
|
223
227
|
id: makeId(),
|
|
224
228
|
domain,
|
|
229
|
+
title: normalizeCaseObjective(input.title),
|
|
225
230
|
status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
|
|
226
231
|
disposition: normalizeDisposition(input.disposition),
|
|
227
232
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
@@ -254,25 +259,46 @@ export function createCase(input, nowIso) {
|
|
|
254
259
|
};
|
|
255
260
|
}
|
|
256
261
|
/**
|
|
257
|
-
* Opens
|
|
262
|
+
* Opens an explicitly selected or unambiguous Case, or creates one. Returns a new array
|
|
258
263
|
* (callers persist it) plus the resolved record and whether it was created.
|
|
259
264
|
* @param {CaseRecord[]} cases
|
|
260
265
|
* @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
|
|
261
266
|
* @param {string} [nowIso]
|
|
262
267
|
* @returns {{ cases: CaseRecord[], record: CaseRecord, created: boolean }}
|
|
263
268
|
*/
|
|
264
|
-
export function openOrCreateCase(cases, input, nowIso) {
|
|
269
|
+
export function openOrCreateCase(cases, input, nowIso, selection = {}) {
|
|
265
270
|
const domain = normalizeDomain(input.domain);
|
|
266
271
|
if (!domain)
|
|
267
272
|
throw new Error('A valid domain is required to open a case.');
|
|
268
|
-
const existing = cases
|
|
273
|
+
const existing = selectExistingCase(cases, domain, selection);
|
|
269
274
|
if (existing)
|
|
270
275
|
return { cases, record: existing, created: false };
|
|
271
276
|
if (cases.length >= MAX_CASES)
|
|
272
277
|
throw new Error(`Cases are limited to ${MAX_CASES}. Delete or export some first.`);
|
|
273
278
|
const record = createCase({ ...input, domain }, nowIso);
|
|
279
|
+
if (cases.some(existingCase => existingCase.id === record.id))
|
|
280
|
+
throw new Error('A unique Case ID could not be allocated. No data was changed; try again.');
|
|
274
281
|
return { cases: [record, ...cases], record, created: true };
|
|
275
282
|
}
|
|
283
|
+
/** Start a separate incident; reuse only the deliberately selected observation. */
|
|
284
|
+
export function createCaseIncident(cases, input, nowIso) {
|
|
285
|
+
const domain = normalizeDomain(input.domain);
|
|
286
|
+
const title = normalizeCaseObjective(input.title);
|
|
287
|
+
if (!domain)
|
|
288
|
+
throw new Error('Enter a valid domain for the incident.');
|
|
289
|
+
if (!title)
|
|
290
|
+
throw new Error('Enter a title that distinguishes this incident.');
|
|
291
|
+
if (typeof input.title === 'string' && input.title.trim().length > MAX_CASE_OBJECTIVE_LENGTH)
|
|
292
|
+
throw new Error(`Incident titles are limited to ${MAX_CASE_OBJECTIVE_LENGTH} characters.`);
|
|
293
|
+
let evidence;
|
|
294
|
+
if (input.reuse) {
|
|
295
|
+
const source = cases.find(record => record.id === input.reuse?.caseId && record.domain === domain);
|
|
296
|
+
evidence = source?.evidenceHistory.find(snapshot => snapshot.id === input.reuse?.snapshotId);
|
|
297
|
+
if (!evidence)
|
|
298
|
+
throw new Error('The selected source observation is no longer available for this domain. No Case was created.');
|
|
299
|
+
}
|
|
300
|
+
return openOrCreateCase(cases, { domain, title, ...(evidence ? { evidence: structuredClone(evidence) } : {}) }, nowIso, { newIncident: true });
|
|
301
|
+
}
|
|
276
302
|
/**
|
|
277
303
|
* Applies a partial update to one case by id, bumping updatedAt. A `note` in
|
|
278
304
|
* the patch is appended (respecting the per-case note bound); a `tags` array
|
|
@@ -293,6 +319,9 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
293
319
|
const current = cases[index];
|
|
294
320
|
if (!current)
|
|
295
321
|
throw new Error('That case no longer exists.');
|
|
322
|
+
if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
|
|
323
|
+
throw new Error('The incident title changed after this draft was started. Reload and review the current title before saving; your draft has not been applied.');
|
|
324
|
+
}
|
|
296
325
|
let notes = current.notes;
|
|
297
326
|
if (patch.note !== undefined) {
|
|
298
327
|
const body = normalizeNoteBody(patch.note);
|
|
@@ -352,6 +381,12 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
352
381
|
}
|
|
353
382
|
let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
|
|
354
383
|
if (patch.observedEffectReview !== undefined) {
|
|
384
|
+
const review = objectRecord(patch.observedEffectReview);
|
|
385
|
+
const context = readCaseRecheckAnswerContext(review.recheck);
|
|
386
|
+
if (context) {
|
|
387
|
+
assertCurrentRecheckQuestion(context, assertions);
|
|
388
|
+
assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
389
|
+
}
|
|
355
390
|
observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
|
|
356
391
|
}
|
|
357
392
|
let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
|
|
@@ -363,6 +398,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
363
398
|
}
|
|
364
399
|
const record = {
|
|
365
400
|
...current,
|
|
401
|
+
title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
|
|
366
402
|
status: patch.closure !== undefined
|
|
367
403
|
? 'resolved'
|
|
368
404
|
: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
@@ -394,8 +430,8 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
394
430
|
}
|
|
395
431
|
/**
|
|
396
432
|
* Records one reviewed conclusion as a single pure Case mutation. The selected
|
|
397
|
-
* observations become bounded Case pins
|
|
398
|
-
* decision cannot point at absent evidence. Counterevidence is retained as a
|
|
433
|
+
* observations become bounded Case pins or reference existing retained pins,
|
|
434
|
+
* so the decision cannot point at absent evidence. Counterevidence is retained as a
|
|
399
435
|
* separate resolved contradiction assertion rather than being hidden inside a
|
|
400
436
|
* favourable disposition.
|
|
401
437
|
*/
|
|
@@ -422,14 +458,36 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
422
458
|
if (!current)
|
|
423
459
|
throw new Error('That case no longer exists.');
|
|
424
460
|
const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
461
|
+
const selectedExisting = new Set();
|
|
462
|
+
for (const item of input.evidence) {
|
|
463
|
+
if (Object.hasOwn(item, 'pin') === Object.hasOwn(item, 'pinId'))
|
|
464
|
+
throw new Error('Select one new pin or one retained pin ID for each conclusion fact.');
|
|
465
|
+
if (Object.hasOwn(item, 'pinId')) {
|
|
466
|
+
if (typeof item.pinId !== 'string' || !existingPinIds.has(item.pinId))
|
|
467
|
+
throw new Error('The selected conclusion pin is no longer retained in this Case.');
|
|
468
|
+
if (selectedExisting.has(item.pinId))
|
|
469
|
+
throw new Error('Select each retained conclusion pin once.');
|
|
470
|
+
selectedExisting.add(item.pinId);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const newEvidence = input.evidence.filter(item => Object.hasOwn(item, 'pin'));
|
|
474
|
+
const withPins = newEvidence.length
|
|
475
|
+
? updateCase(cases, id, { evidencePins: newEvidence.map(item => item.pin) }, now)
|
|
476
|
+
: { cases, record: current };
|
|
428
477
|
const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
|
|
429
|
-
if (addedPins.length !==
|
|
478
|
+
if (addedPins.length !== newEvidence.length) {
|
|
430
479
|
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
431
480
|
}
|
|
432
|
-
|
|
481
|
+
let nextAdded = 0;
|
|
482
|
+
const selectedPins = input.evidence.map(item => {
|
|
483
|
+
const pin = Object.hasOwn(item, 'pinId')
|
|
484
|
+
? withPins.record.evidencePins.find(pin => pin.id === item.pinId)
|
|
485
|
+
: addedPins[nextAdded++];
|
|
486
|
+
if (!pin)
|
|
487
|
+
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
488
|
+
return pin;
|
|
489
|
+
});
|
|
490
|
+
const supportingPinIds = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
|
|
433
491
|
let concluded = updateCase(withPins.cases, id, {
|
|
434
492
|
disposition,
|
|
435
493
|
reviewReasonCode,
|
|
@@ -439,7 +497,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
439
497
|
evidencePinIds: supportingPinIds,
|
|
440
498
|
},
|
|
441
499
|
}, now);
|
|
442
|
-
const contradictionRelations =
|
|
500
|
+
const contradictionRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
|
|
443
501
|
? [{ evidencePinId: pin.id, stance: 'contradicts' }]
|
|
444
502
|
: []));
|
|
445
503
|
if (contradictionRelations.length) {
|
|
@@ -454,7 +512,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
454
512
|
},
|
|
455
513
|
}, now);
|
|
456
514
|
}
|
|
457
|
-
const unresolvedRelations =
|
|
515
|
+
const unresolvedRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
|
|
458
516
|
? [{ evidencePinId: pin.id, stance: 'unresolved' }]
|
|
459
517
|
: []));
|
|
460
518
|
if (unresolvedRelations.length) {
|
|
@@ -505,6 +563,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
505
563
|
const current = cases.find((item) => item.id === id);
|
|
506
564
|
if (!current)
|
|
507
565
|
throw new Error('That case no longer exists.');
|
|
566
|
+
if (input.recheck && input.observationHostname !== input.recheck.targetHostname)
|
|
567
|
+
throw new Error('This recheck concerns a different or unknown hostname. Recollect the saved question target before recording its answer.');
|
|
508
568
|
const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
509
569
|
const withPin = updateCase(cases, id, {
|
|
510
570
|
evidencePin: {
|
|
@@ -516,6 +576,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
516
576
|
sourceState: 'reviewed',
|
|
517
577
|
observedAt: input.observedAt,
|
|
518
578
|
collectionDepth: input.collectionDepth,
|
|
579
|
+
observationHostname: input.observationHostname,
|
|
519
580
|
completeness: input.completeness,
|
|
520
581
|
truncated: false,
|
|
521
582
|
limitations: input.limitations,
|
|
@@ -534,6 +595,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
534
595
|
evidencePinId: comparisonPin.id,
|
|
535
596
|
followUpAt: input.followUpAt,
|
|
536
597
|
limitations: input.limitations,
|
|
598
|
+
...(input.recheck ? { recheck: input.recheck } : {}),
|
|
537
599
|
},
|
|
538
600
|
}, now);
|
|
539
601
|
}
|