@slicedearth/whoisleuth-cli 2.0.1 → 2.2.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/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +16 -9
- package/docs/cli.md +16 -2
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +47 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +2 -2
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-record-operations.mjs +235 -0
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +235 -61
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/interchange/external-findings-import.mjs +113 -45
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- package/cli/artifact-structure.mjs +0 -2507
package/package.json
CHANGED
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/slicedearth/whoisleuth/issues"
|
|
35
35
|
},
|
|
36
|
-
"version": "2.0
|
|
36
|
+
"version": "2.2.0",
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@peculiar/x509": "2.0.0",
|
|
39
39
|
"maxmind": "5.0.7",
|
|
40
40
|
"parse5": "8.0.1",
|
|
41
41
|
"reflect-metadata": "0.2.2",
|
|
42
42
|
"tldts": "7.4.11",
|
|
43
|
-
"undici": "8.10.
|
|
43
|
+
"undici": "8.10.1"
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"bin/**/*.mjs",
|
|
@@ -413,6 +413,15 @@ export function latestCaseEvidence(record) {
|
|
|
413
413
|
const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
414
414
|
return history.at(-1) ?? null;
|
|
415
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Returns the exact hostname from the latest retained observation when it is
|
|
418
|
+
* available. The registrable Case identity is the conservative fallback; an
|
|
419
|
+
* older hostname is never substituted for a newer observation with unknown
|
|
420
|
+
* hostname context.
|
|
421
|
+
*/
|
|
422
|
+
export function caseLookupTarget(record) {
|
|
423
|
+
return latestCaseEvidence(record)?.inputHostname || record.domain;
|
|
424
|
+
}
|
|
416
425
|
// ---------------------------------------------------------------------------
|
|
417
426
|
// Material-change comparison (pure; no Svelte, DOM, or persistence access)
|
|
418
427
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
|
|
2
|
-
import { CASE_REPORT_SCHEMA, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
|
|
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";
|
|
3
3
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
4
4
|
import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
|
|
5
5
|
import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
@@ -51,13 +51,13 @@ export function normalizeCaseStore(raw) {
|
|
|
51
51
|
throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
52
52
|
}
|
|
53
53
|
if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
|
|
54
|
-
throw new TypeError(`Case schema ${sourceVersion} is not part of the
|
|
54
|
+
throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
55
55
|
}
|
|
56
56
|
if (sourceVersion > CASE_SCHEMA_VERSION) {
|
|
57
57
|
throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
58
58
|
}
|
|
59
59
|
if (sourceVersion === CASE_SCHEMA_VERSION) {
|
|
60
|
-
|
|
60
|
+
assertCurrentCaseShape(raw);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
const byDomain = new Map();
|
|
@@ -76,17 +76,23 @@ export function normalizeCaseStore(raw) {
|
|
|
76
76
|
assignUniqueIds(cases);
|
|
77
77
|
return { version: CASE_SCHEMA_VERSION, cases };
|
|
78
78
|
}
|
|
79
|
-
function
|
|
79
|
+
function assertCurrentCaseShape(raw) {
|
|
80
80
|
for (const item of boundedCaseList(raw).items) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const snapshot of evidenceHistory) {
|
|
81
|
+
const itemRecord = objectRecord(item);
|
|
82
|
+
const evidenceHistory = itemRecord.evidenceHistory;
|
|
83
|
+
for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
|
|
85
84
|
const record = objectRecord(snapshot);
|
|
86
85
|
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
|
|
87
86
|
throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
|
|
88
87
|
}
|
|
89
88
|
}
|
|
89
|
+
const actions = itemRecord.actions;
|
|
90
|
+
for (const action of Array.isArray(actions) ? actions : []) {
|
|
91
|
+
const record = objectRecord(action);
|
|
92
|
+
if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
|
|
93
|
+
throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
/**
|
|
@@ -388,7 +394,7 @@ function caseCollectionImportEnvelope(importedRaw) {
|
|
|
388
394
|
}
|
|
389
395
|
for (const reportValue of reports) {
|
|
390
396
|
const report = objectRecord(reportValue);
|
|
391
|
-
assertOnlyEnvelopeKeys(report, rootVersion ===
|
|
397
|
+
assertOnlyEnvelopeKeys(report, rootVersion === PUBLIC_CASE_SCHEMA_VERSION ? CLI_CASE_PACK_PUBLIC_REPORT_KEYS : CLI_CASE_PACK_REPORT_KEYS, 'CLI Case-pack report');
|
|
392
398
|
if (report.schema !== CASE_REPORT_SCHEMA
|
|
393
399
|
|| !caseReportVersionMatchesCase(rootVersion, report.schemaVersion)
|
|
394
400
|
|| report.generatedAt !== root.exportedAt) {
|
|
@@ -420,7 +426,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
420
426
|
const importedVersion = parseStoreVersion(importedEnvelope);
|
|
421
427
|
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
|
|
422
428
|
&& !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
|
|
423
|
-
throw new Error(`Case schema ${importedVersion} is not part of the
|
|
429
|
+
throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
424
430
|
}
|
|
425
431
|
if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
|
|
426
432
|
throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
@@ -430,7 +436,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
430
436
|
throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
|
|
431
437
|
}
|
|
432
438
|
if (importedVersion === CASE_SCHEMA_VERSION)
|
|
433
|
-
|
|
439
|
+
assertCurrentCaseShape(importedEnvelope);
|
|
434
440
|
const local = normalizeCaseStore(localCases).cases;
|
|
435
441
|
const supportedImportedVersion = importedVersion ?? 0;
|
|
436
442
|
const byDomain = new Map(local.map((item) => [item.domain, item]));
|
|
@@ -1,11 +1,101 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
3
4
|
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";
|
|
4
5
|
import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
5
6
|
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";
|
|
6
7
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
7
8
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
8
9
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
10
|
+
export const MAX_CASE_OBJECTIVE_LENGTH = 320;
|
|
11
|
+
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
12
|
+
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
13
|
+
const OBJECTIVE_PREFIX = 'Objective: ';
|
|
14
|
+
const RETENTION_SEPARATOR = ' | URL retained: ';
|
|
15
|
+
function boundedContextText(value, maximum) {
|
|
16
|
+
return typeof value === 'string'
|
|
17
|
+
? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
18
|
+
: '';
|
|
19
|
+
}
|
|
20
|
+
export function normalizeCaseObjective(value) {
|
|
21
|
+
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
22
|
+
}
|
|
23
|
+
export function parseIncidentUrlContext(value) {
|
|
24
|
+
if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
25
|
+
return null;
|
|
26
|
+
if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
|
|
27
|
+
return null;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
36
|
+
return null;
|
|
37
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
38
|
+
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
39
|
+
if (!registrableDomain)
|
|
40
|
+
return null;
|
|
41
|
+
const exactUrl = parsed.toString();
|
|
42
|
+
if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
43
|
+
return null;
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
exactUrl,
|
|
46
|
+
hostname,
|
|
47
|
+
registrableDomain,
|
|
48
|
+
originUrl: parsed.origin,
|
|
49
|
+
hasPath: parsed.pathname !== '/',
|
|
50
|
+
hasQuery: Boolean(parsed.search),
|
|
51
|
+
hasFragment: Boolean(parsed.hash),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function caseInvestigationContext(record) {
|
|
55
|
+
if (!record)
|
|
56
|
+
return null;
|
|
57
|
+
for (const assertion of [...record.assertions].reverse()) {
|
|
58
|
+
if (assertion.kind !== 'next_step' || assertion.state !== 'open'
|
|
59
|
+
|| !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
|
|
60
|
+
continue;
|
|
61
|
+
const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
|
|
62
|
+
const parsed = parseIncidentUrlContext(incidentUrl);
|
|
63
|
+
if (!parsed || parsed.registrableDomain !== record.domain)
|
|
64
|
+
continue;
|
|
65
|
+
const rationale = assertion.rationale ?? '';
|
|
66
|
+
const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
|
|
67
|
+
const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
|
|
68
|
+
? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
|
|
69
|
+
: '';
|
|
70
|
+
const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
|
|
71
|
+
if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
|
|
72
|
+
continue;
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
objective,
|
|
75
|
+
incidentUrl: parsed.exactUrl,
|
|
76
|
+
urlRetention: retention,
|
|
77
|
+
assertionId: assertion.id,
|
|
78
|
+
updatedAt: assertion.updatedAt,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function caseInvestigationContextAssertion(input) {
|
|
84
|
+
const objective = normalizeCaseObjective(input.objective);
|
|
85
|
+
if (!objective)
|
|
86
|
+
throw new Error('Enter the investigation objective before retaining Incident context.');
|
|
87
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
88
|
+
if (!parsed)
|
|
89
|
+
throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
|
|
90
|
+
const retention = input.retainExactUrl ? 'exact' : 'origin_only';
|
|
91
|
+
const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
|
|
94
|
+
rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
|
|
95
|
+
retainedUrl,
|
|
96
|
+
retention,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
9
99
|
// ---------------------------------------------------------------------------
|
|
10
100
|
// Case normalization
|
|
11
101
|
// ---------------------------------------------------------------------------
|
|
@@ -300,3 +390,148 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
300
390
|
next[index] = record;
|
|
301
391
|
return { cases: next, record };
|
|
302
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Records one reviewed conclusion as a single pure Case mutation. The selected
|
|
395
|
+
* observations become bounded Case pins before the decision is created, so the
|
|
396
|
+
* decision cannot point at absent evidence. Counterevidence is retained as a
|
|
397
|
+
* separate resolved contradiction assertion rather than being hidden inside a
|
|
398
|
+
* favourable disposition.
|
|
399
|
+
*/
|
|
400
|
+
export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
401
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
402
|
+
const disposition = normalizeDisposition(input.disposition);
|
|
403
|
+
const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
|
|
404
|
+
if (disposition === 'unreviewed') {
|
|
405
|
+
throw new Error('Select a reviewed disposition before recording a conclusion.');
|
|
406
|
+
}
|
|
407
|
+
if (!reviewReasonCode) {
|
|
408
|
+
throw new Error('Select the reviewed reason before recording a conclusion.');
|
|
409
|
+
}
|
|
410
|
+
if (!Array.isArray(input.evidence) || !input.evidence.length) {
|
|
411
|
+
throw new Error('Select at least one observed fact for this conclusion.');
|
|
412
|
+
}
|
|
413
|
+
const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
|
|
414
|
+
if (invalidStance)
|
|
415
|
+
throw new Error('A conclusion evidence relationship is invalid.');
|
|
416
|
+
if (!input.evidence.some((item) => item.stance === 'supports')) {
|
|
417
|
+
throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
|
|
418
|
+
}
|
|
419
|
+
const current = cases.find((item) => item.id === id);
|
|
420
|
+
if (!current)
|
|
421
|
+
throw new Error('That case no longer exists.');
|
|
422
|
+
const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
423
|
+
const withPins = updateCase(cases, id, {
|
|
424
|
+
evidencePins: input.evidence.map((item) => item.pin),
|
|
425
|
+
}, now);
|
|
426
|
+
const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
|
|
427
|
+
if (addedPins.length !== input.evidence.length) {
|
|
428
|
+
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
429
|
+
}
|
|
430
|
+
const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
|
|
431
|
+
let concluded = updateCase(withPins.cases, id, {
|
|
432
|
+
disposition,
|
|
433
|
+
reviewReasonCode,
|
|
434
|
+
decision: {
|
|
435
|
+
summary: input.summary,
|
|
436
|
+
rationale: input.rationale,
|
|
437
|
+
evidencePinIds: supportingPinIds,
|
|
438
|
+
},
|
|
439
|
+
}, now);
|
|
440
|
+
const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
|
|
441
|
+
? [{ evidencePinId: pin.id, stance: 'contradicts' }]
|
|
442
|
+
: []));
|
|
443
|
+
if (contradictionRelations.length) {
|
|
444
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
445
|
+
concluded = updateCase(concluded.cases, id, {
|
|
446
|
+
assertion: {
|
|
447
|
+
kind: 'contradiction',
|
|
448
|
+
statement: `Counterevidence considered for: ${retainedSummary}`,
|
|
449
|
+
rationale: input.rationale,
|
|
450
|
+
evidenceRelations: contradictionRelations,
|
|
451
|
+
state: 'resolved',
|
|
452
|
+
},
|
|
453
|
+
}, now);
|
|
454
|
+
}
|
|
455
|
+
const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
|
|
456
|
+
? [{ evidencePinId: pin.id, stance: 'unresolved' }]
|
|
457
|
+
: []));
|
|
458
|
+
if (unresolvedRelations.length) {
|
|
459
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
460
|
+
concluded = updateCase(concluded.cases, id, {
|
|
461
|
+
assertion: {
|
|
462
|
+
kind: 'unknown',
|
|
463
|
+
statement: `Unresolved evidence considered for: ${retainedSummary}`,
|
|
464
|
+
rationale: input.rationale,
|
|
465
|
+
evidenceRelations: unresolvedRelations,
|
|
466
|
+
state: 'open',
|
|
467
|
+
},
|
|
468
|
+
}, now);
|
|
469
|
+
}
|
|
470
|
+
return concluded;
|
|
471
|
+
}
|
|
472
|
+
export function recordCaseInvestigationContext(cases, id, input, nowIso) {
|
|
473
|
+
const current = cases.find((item) => item.id === id);
|
|
474
|
+
if (!current)
|
|
475
|
+
throw new Error('That case no longer exists.');
|
|
476
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
477
|
+
if (!parsed || parsed.registrableDomain !== current.domain) {
|
|
478
|
+
throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
|
|
479
|
+
}
|
|
480
|
+
const context = caseInvestigationContextAssertion(input);
|
|
481
|
+
const existing = caseInvestigationContext(current);
|
|
482
|
+
return updateCase(cases, id, existing
|
|
483
|
+
? {
|
|
484
|
+
assertionUpdate: {
|
|
485
|
+
id: existing.assertionId,
|
|
486
|
+
statement: context.statement,
|
|
487
|
+
rationale: context.rationale,
|
|
488
|
+
state: 'open',
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
: {
|
|
492
|
+
assertion: {
|
|
493
|
+
kind: 'next_step',
|
|
494
|
+
statement: context.statement,
|
|
495
|
+
rationale: context.rationale,
|
|
496
|
+
evidenceRelations: [],
|
|
497
|
+
state: 'open',
|
|
498
|
+
},
|
|
499
|
+
}, nowIso);
|
|
500
|
+
}
|
|
501
|
+
export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
502
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
503
|
+
const current = cases.find((item) => item.id === id);
|
|
504
|
+
if (!current)
|
|
505
|
+
throw new Error('That case no longer exists.');
|
|
506
|
+
const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
507
|
+
const withPin = updateCase(cases, id, {
|
|
508
|
+
evidencePin: {
|
|
509
|
+
field: 'case.recheck_comparison',
|
|
510
|
+
category: 'recheck',
|
|
511
|
+
label: 'Recheck comparison',
|
|
512
|
+
value: input.comparisonSummary,
|
|
513
|
+
source: input.source,
|
|
514
|
+
sourceState: 'reviewed',
|
|
515
|
+
observedAt: input.observedAt,
|
|
516
|
+
collectionDepth: input.collectionDepth,
|
|
517
|
+
completeness: input.completeness,
|
|
518
|
+
truncated: false,
|
|
519
|
+
limitations: input.limitations,
|
|
520
|
+
},
|
|
521
|
+
}, now);
|
|
522
|
+
const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
|
|
523
|
+
if (!comparisonPin)
|
|
524
|
+
throw new Error('The recheck comparison could not be retained.');
|
|
525
|
+
return updateCase(withPin.cases, id, {
|
|
526
|
+
observedEffectReview: {
|
|
527
|
+
state: input.state,
|
|
528
|
+
observedAt: input.observedAt,
|
|
529
|
+
sourceClass: 'analyst',
|
|
530
|
+
source: input.source,
|
|
531
|
+
completeness: input.completeness,
|
|
532
|
+
evidencePinId: comparisonPin.id,
|
|
533
|
+
followUpAt: input.followUpAt,
|
|
534
|
+
limitations: input.limitations,
|
|
535
|
+
},
|
|
536
|
+
}, now);
|
|
537
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// deliberately separate from collected evidence snapshots: a pin describes a
|
|
3
3
|
// fact selected by an analyst, a decision records analyst reasoning, and an
|
|
4
4
|
// action records a reviewed external or internal follow-up.
|
|
5
|
-
import { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
|
|
5
|
+
import { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
6
6
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
7
7
|
export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, };
|
|
8
8
|
export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
|
|
@@ -118,8 +118,8 @@ const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
|
|
|
118
118
|
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
119
119
|
function currentActionNormalizationOptions(validEvidencePinIds) {
|
|
120
120
|
return validEvidencePinIds
|
|
121
|
-
? { sourceVersion:
|
|
122
|
-
: { sourceVersion:
|
|
121
|
+
? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
|
|
122
|
+
: { sourceVersion: CASE_SCHEMA_VERSION };
|
|
123
123
|
}
|
|
124
124
|
function record(value) {
|
|
125
125
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -668,6 +668,9 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
668
668
|
: 'internal_review',
|
|
669
669
|
recipient,
|
|
670
670
|
contactSource: text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied',
|
|
671
|
+
routeObservedAt: options.sourceVersion != null && options.sourceVersion < 14
|
|
672
|
+
? null
|
|
673
|
+
: optionalIso(item.routeObservedAt, options),
|
|
671
674
|
contactLimitations: limitations(item.contactLimitations),
|
|
672
675
|
dueAt: optionalIso(item.dueAt, options),
|
|
673
676
|
state: history.state,
|
|
@@ -699,7 +702,7 @@ function mergeNormalizedActions(left, right, fallback) {
|
|
|
699
702
|
history: [...left.history, ...right.history],
|
|
700
703
|
historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
|
|
701
704
|
historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
|
|
702
|
-
}, fallback, { sourceVersion:
|
|
705
|
+
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
703
706
|
}
|
|
704
707
|
function boundActionCollection(actions, fallback) {
|
|
705
708
|
const keep = new Set();
|
|
@@ -719,7 +722,7 @@ function boundActionCollection(actions, fallback) {
|
|
|
719
722
|
let bounded = actions.map((action) => {
|
|
720
723
|
const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
|
|
721
724
|
const omitted = action.historyOmitted + action.history.length - history.length;
|
|
722
|
-
return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion:
|
|
725
|
+
return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
723
726
|
});
|
|
724
727
|
while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
|
|
725
728
|
const candidate = bounded
|
|
@@ -734,7 +737,7 @@ function boundActionCollection(actions, fallback) {
|
|
|
734
737
|
...action,
|
|
735
738
|
history: action.history.filter((event) => event.id !== candidate.event.id),
|
|
736
739
|
historyOmitted: action.historyOmitted + 1,
|
|
737
|
-
}, fallback, { sourceVersion:
|
|
740
|
+
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
|
|
738
741
|
}
|
|
739
742
|
if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
|
|
740
743
|
const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
|
|
@@ -859,10 +862,10 @@ export function appendCaseAction(current, raw, now) {
|
|
|
859
862
|
historyOmitted: 0,
|
|
860
863
|
createdAt: now,
|
|
861
864
|
metadataUpdatedAt: now,
|
|
862
|
-
}, now, { sourceVersion:
|
|
865
|
+
}, now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
863
866
|
if (!created)
|
|
864
867
|
throw new Error('An action requires a recipient or internal owner.');
|
|
865
|
-
return normalizeCaseActions([...current, created], now, { sourceVersion:
|
|
868
|
+
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
866
869
|
}
|
|
867
870
|
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
868
871
|
const action = current.find((item) => item.id === actionId);
|
|
@@ -927,6 +930,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
927
930
|
type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
|
|
928
931
|
recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
|
|
929
932
|
contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
|
|
933
|
+
routeObservedAt: Object.hasOwn(patch, 'routeObservedAt') ? patch.routeObservedAt : existing.routeObservedAt,
|
|
930
934
|
contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
|
|
931
935
|
dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
|
|
932
936
|
followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
|
|
@@ -940,7 +944,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
940
944
|
let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
|
|
941
945
|
if (!updated)
|
|
942
946
|
throw new Error('An action requires a recipient or internal owner.');
|
|
943
|
-
const materialChanged = ['type', 'recipient', 'contactSource', 'contactLimitations', 'originActionId']
|
|
947
|
+
const materialChanged = ['type', 'recipient', 'contactSource', 'routeObservedAt', 'contactLimitations', 'originActionId']
|
|
944
948
|
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
945
949
|
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
946
950
|
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|