@slicedearth/whoisleuth-cli 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { normalizeCaseObjective, parseIncidentUrlContext, } from "./case-incident-context.mjs";
|
|
4
4
|
import { selectExistingCase } from "./case-selection.mjs";
|
|
5
5
|
import { emptyCaseWorkflowMetadata, updateCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
|
|
6
|
+
import { assertCaseResponseObject, readCaseResponseObject, readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
7
|
+
import { correctionDelivery, readCasePacketCorrection, readCaseDeliveryPacketReceipt } from "./case-packet-correction.mjs";
|
|
6
8
|
import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
|
|
7
9
|
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";
|
|
8
10
|
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
@@ -10,7 +12,7 @@ import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contrac
|
|
|
10
12
|
import { caseRecordVersionInput } from "./case-record-version-input.mjs";
|
|
11
13
|
import { readCaseAttachments } from "./case-attachment-model.mjs";
|
|
12
14
|
import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
|
|
13
|
-
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
15
|
+
import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, assertCaseObjectObservationOutcome, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
14
16
|
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
15
17
|
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";
|
|
16
18
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
@@ -66,6 +68,11 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
66
68
|
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
|
|
67
69
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
68
70
|
const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
|
|
71
|
+
for (const action of actions)
|
|
72
|
+
for (const event of action.history)
|
|
73
|
+
if (event.packetReceipt
|
|
74
|
+
&& (event.packetReceipt.caseId !== record.id || event.packetReceipt.target !== domain))
|
|
75
|
+
throw new TypeError('The packet receipt belongs to a different Case or target.');
|
|
69
76
|
const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
|
|
70
77
|
const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
|
|
71
78
|
const sightingIds = new Set(sightings.map((item) => item.id));
|
|
@@ -80,7 +87,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
80
87
|
domain,
|
|
81
88
|
title: normalizeCaseObjective(record.title),
|
|
82
89
|
status: caseStatusRequiresClosure(normalizedStatus)
|
|
83
|
-
&& closures.records.
|
|
90
|
+
&& !closures.records.some(closure => closure.responseObject === undefined) && !closures.preV13HistoryUnavailable
|
|
84
91
|
? 'reviewing'
|
|
85
92
|
: normalizedStatus,
|
|
86
93
|
disposition: normalizeDisposition(record.disposition),
|
|
@@ -117,6 +124,13 @@ export function createCase(input, nowIso) {
|
|
|
117
124
|
throw new Error('A valid domain is required to open a case.');
|
|
118
125
|
const noteBody = normalizeNoteBody(input.note);
|
|
119
126
|
const source = normalizeSource(input.source);
|
|
127
|
+
const workflowMetadata = updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now);
|
|
128
|
+
const scopedCase = { domain, workflowMetadata };
|
|
129
|
+
for (const raw of [input.evidencePin, ...(Array.isArray(input.evidencePins) ? input.evidencePins : []), input.observedEffectReview, input.closure])
|
|
130
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
|
|
131
|
+
for (const object of readCaseResponseObjects(objectRecord(input.action).responseObjects) ?? [])
|
|
132
|
+
assertCaseResponseObject(object, scopedCase);
|
|
133
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(input.assertion).recheck).responseObject), scopedCase);
|
|
120
134
|
const evidencePins = input.evidencePins !== undefined
|
|
121
135
|
? appendCaseEvidencePins([], input.evidencePins, now)
|
|
122
136
|
: input.evidencePin !== undefined
|
|
@@ -137,13 +151,19 @@ export function createCase(input, nowIso) {
|
|
|
137
151
|
? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
|
|
138
152
|
: normalizeCaseObservedEffectHistory(undefined, now);
|
|
139
153
|
for (const review of observedEffects.reviews) {
|
|
154
|
+
const pin = evidencePins.find(item => item.id === review.evidencePinId);
|
|
155
|
+
if (review.responseObject && pin && !sameCaseResponseObject(review.responseObject, pin.responseObject))
|
|
156
|
+
throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
|
|
157
|
+
if (review.responseObject && review.state === 'not_reproduced' && !review.recheck)
|
|
158
|
+
throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
|
|
159
|
+
assertCaseObjectObservationOutcome(review.objectOutcome, review.recheck, review.completeness, evidencePins, pin, review.observedAt);
|
|
140
160
|
if (!review.recheck)
|
|
141
161
|
continue;
|
|
142
162
|
assertCurrentRecheckQuestion(review.recheck, assertions);
|
|
143
163
|
assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
|
|
144
164
|
}
|
|
145
165
|
const closures = input.closure !== undefined
|
|
146
|
-
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
166
|
+
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions, evidencePins)
|
|
147
167
|
: normalizeCaseClosureHistory(undefined, now);
|
|
148
168
|
if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
|
|
149
169
|
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
@@ -152,12 +172,12 @@ export function createCase(input, nowIso) {
|
|
|
152
172
|
id: makeId(),
|
|
153
173
|
domain,
|
|
154
174
|
title: normalizeCaseObjective(input.title),
|
|
155
|
-
status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
|
|
175
|
+
status: input.closure !== undefined && objectRecord(input.closure).responseObject === undefined ? 'resolved' : normalizeStatus(input.status),
|
|
156
176
|
disposition: normalizeDisposition(input.disposition),
|
|
157
177
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
158
178
|
brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
|
|
159
179
|
tags: normalizeTags(input.tags),
|
|
160
|
-
workflowMetadata
|
|
180
|
+
workflowMetadata,
|
|
161
181
|
notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
|
|
162
182
|
source,
|
|
163
183
|
evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
|
|
@@ -246,6 +266,21 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
246
266
|
const current = cases[index];
|
|
247
267
|
if (!current)
|
|
248
268
|
throw new Error('That case no longer exists.');
|
|
269
|
+
if (patch.expectedResponseContext !== undefined && patch.expectedResponseContext !== JSON.stringify(current))
|
|
270
|
+
throw new TypeError('The Case response context changed after preview. Review the current delivery, scope and evidence before creating this draft.');
|
|
271
|
+
const workflowMetadata = updateCaseWorkflowMetadata(current.workflowMetadata
|
|
272
|
+
?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now);
|
|
273
|
+
const scopedCase = { ...current, workflowMetadata };
|
|
274
|
+
for (const raw of [patch.evidencePin, ...(Array.isArray(patch.evidencePins) ? patch.evidencePins : []), patch.observedEffectReview, patch.closure]) {
|
|
275
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
|
|
276
|
+
}
|
|
277
|
+
for (const raw of [patch.action, patch.actionUpdate, objectRecord(patch.actionUpdate).transition]) {
|
|
278
|
+
for (const object of readCaseResponseObjects(objectRecord(raw).responseObjects) ?? [])
|
|
279
|
+
assertCaseResponseObject(object, scopedCase);
|
|
280
|
+
}
|
|
281
|
+
for (const raw of [patch.assertion, patch.assertionUpdate, patch.observedEffectReview]) {
|
|
282
|
+
assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(raw).recheck).responseObject), scopedCase);
|
|
283
|
+
}
|
|
249
284
|
if (patch.evidenceLink !== undefined && patch.evidenceLinkWithdrawal !== undefined)
|
|
250
285
|
throw new TypeError('Record or withdraw one evidence relationship at a time.');
|
|
251
286
|
if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
|
|
@@ -284,6 +319,17 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
284
319
|
evidencePins = appendCaseEvidencePin(evidencePins, patch.evidencePin, now);
|
|
285
320
|
}
|
|
286
321
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
322
|
+
const correctionInput = patch.action !== undefined ? objectRecord(patch.action) : patch.actionUpdate !== undefined
|
|
323
|
+
? { ...current.actions.find(action => action.id === objectRecord(patch.actionUpdate).id), ...objectRecord(patch.actionUpdate) } : null;
|
|
324
|
+
if (correctionInput?.correction !== undefined)
|
|
325
|
+
correctionDelivery(current.actions, {
|
|
326
|
+
originActionId: typeof correctionInput.originActionId === 'string' ? correctionInput.originActionId : null,
|
|
327
|
+
recipient: typeof correctionInput.recipient === 'string' ? correctionInput.recipient : '',
|
|
328
|
+
responseObjects: readCaseResponseObjects(correctionInput.responseObjects), correction: readCasePacketCorrection(correctionInput.correction),
|
|
329
|
+
}, { caseId: current.id, target: current.domain, selectedPinIds: pinIds });
|
|
330
|
+
const transitionReceipt = readCaseDeliveryPacketReceipt(objectRecord(objectRecord(patch.actionUpdate).transition).packetReceipt);
|
|
331
|
+
if (transitionReceipt && (transitionReceipt.caseId !== current.id || transitionReceipt.target !== current.domain))
|
|
332
|
+
throw new TypeError('The packet receipt belongs to a different Case or target.');
|
|
287
333
|
let decisions = current.decisions;
|
|
288
334
|
if (patch.decision !== undefined) {
|
|
289
335
|
decisions = appendCaseDecision(current.decisions, patch.decision, now, pinIds);
|
|
@@ -295,6 +341,16 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
295
341
|
if (patch.actionUpdate !== undefined) {
|
|
296
342
|
actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
|
|
297
343
|
}
|
|
344
|
+
if (correctionInput?.correction !== undefined || transitionReceipt) {
|
|
345
|
+
for (const original of current.actions) {
|
|
346
|
+
const retained = actions.find(action => action.id === original.id);
|
|
347
|
+
if (!retained || original.history.some(event => !retained.history.some(next => JSON.stringify(next) === JSON.stringify(event)))
|
|
348
|
+
|| (patch.action !== undefined && JSON.stringify(retained) !== JSON.stringify(original)))
|
|
349
|
+
throw new TypeError('This linked operation would omit or change historical delivery records within the Case bounds. No correction or receipt was retained.');
|
|
350
|
+
}
|
|
351
|
+
if (transitionReceipt && !actions.find(action => action.id === transitionReceipt.actionId)?.history.some(event => JSON.stringify(event.packetReceipt) === JSON.stringify(transitionReceipt)))
|
|
352
|
+
throw new TypeError('The exact delivery receipt exceeds the retained history bounds. No delivery was recorded.');
|
|
353
|
+
}
|
|
298
354
|
let assertions = current.assertions;
|
|
299
355
|
if (patch.assertion !== undefined) {
|
|
300
356
|
assertions = appendCaseAssertion(current.assertions, patch.assertion, now, pinIds);
|
|
@@ -326,22 +382,28 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
326
382
|
assertCurrentRecheckQuestion(context, assertions);
|
|
327
383
|
assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
328
384
|
}
|
|
385
|
+
const responseObject = readCaseResponseObject(review.responseObject);
|
|
386
|
+
const pin = evidencePins.find(item => item.id === review.evidencePinId);
|
|
387
|
+
assertCaseObjectObservationOutcome(review.objectOutcome, context, String(review.completeness), evidencePins, pin, typeof review.observedAt === 'string' ? review.observedAt : null);
|
|
388
|
+
if (responseObject && pin && !sameCaseResponseObject(responseObject, pin.responseObject))
|
|
389
|
+
throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
|
|
390
|
+
if (responseObject && review.state === 'not_reproduced' && !context)
|
|
391
|
+
throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
|
|
329
392
|
observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
|
|
330
393
|
}
|
|
331
394
|
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));
|
|
332
395
|
if (patch.closure !== undefined) {
|
|
333
|
-
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
396
|
+
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions, evidencePins);
|
|
334
397
|
}
|
|
335
398
|
if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
|
|
336
399
|
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
337
400
|
}
|
|
338
401
|
const record = {
|
|
339
402
|
...current,
|
|
340
|
-
workflowMetadata
|
|
341
|
-
?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now),
|
|
403
|
+
workflowMetadata,
|
|
342
404
|
title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
|
|
343
405
|
status: patch.closure !== undefined
|
|
344
|
-
? 'resolved'
|
|
406
|
+
? objectRecord(patch.closure).responseObject !== undefined ? current.status : 'resolved'
|
|
345
407
|
: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
346
408
|
disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
|
|
347
409
|
reviewReasonCode: patch.reviewReasonCode !== undefined
|
|
@@ -504,6 +566,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
504
566
|
observedAt: input.observedAt,
|
|
505
567
|
collectionDepth: input.collectionDepth,
|
|
506
568
|
observationHostname: input.observationHostname,
|
|
569
|
+
...(input.responseObject ? { responseObject: input.responseObject } : {}),
|
|
507
570
|
completeness: input.completeness,
|
|
508
571
|
truncated: input.comparisonTruncated === true
|
|
509
572
|
|| (typeof input.comparisonSummary === 'string' && input.comparisonSummary.length > MAX_RESPONSE_VALUE_LENGTH),
|
|
@@ -524,6 +587,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
|
524
587
|
followUpAt: input.followUpAt,
|
|
525
588
|
limitations: input.limitations,
|
|
526
589
|
...(input.recheck ? { recheck: input.recheck } : {}),
|
|
590
|
+
...(input.responseObject ? { responseObject: input.responseObject } : {}),
|
|
591
|
+
...(input.objectOutcome ? { objectOutcome: input.objectOutcome } : {}),
|
|
527
592
|
},
|
|
528
593
|
}, now);
|
|
529
594
|
}
|
|
@@ -102,15 +102,26 @@ const CASE_FIELD_RULES = Object.freeze({
|
|
|
102
102
|
}),
|
|
103
103
|
source: preservedField('source'),
|
|
104
104
|
evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
|
|
105
|
-
evidencePins: fieldRule('evidencePins',
|
|
105
|
+
evidencePins: fieldRule('evidencePins', PUBLIC_REDACT, (record, profile) => structuredClone(record.evidencePins).map(pin => {
|
|
106
|
+
if (profile !== 'public')
|
|
107
|
+
return pin;
|
|
108
|
+
const { responseObject: _scope, infrastructureObservation: _infrastructure, ...publicPin } = pin;
|
|
109
|
+
return publicPin;
|
|
110
|
+
}), { nestedSensitiveFields: ['responseObject', 'infrastructureObservation'], audienceExclusions: { public: { label: 'Exact-object evidence scope and infrastructure observations', order: 4, sinceVersion: 18 } } }),
|
|
106
111
|
evidenceLinks: fieldRule('evidenceLinks', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? undefined : structuredClone(record.evidenceLinks)), { audienceExclusions: { public: { label: 'Analyst evidence relationships', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } } }),
|
|
107
112
|
decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
|
|
108
113
|
actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
|
|
109
114
|
? []
|
|
110
115
|
: profile === 'trusted'
|
|
111
|
-
? structuredClone(record.actions).map((item) =>
|
|
116
|
+
? structuredClone(record.actions).map((item) => {
|
|
117
|
+
const { correction: _privateCorrection, ...shared } = item;
|
|
118
|
+
return { ...shared, recipient: '[redacted]', history: shared.history.map(event => {
|
|
119
|
+
const { packetReceipt: _privateReceipt, ...sharedEvent } = event;
|
|
120
|
+
return sharedEvent;
|
|
121
|
+
}) };
|
|
122
|
+
})
|
|
112
123
|
: structuredClone(record.actions)), {
|
|
113
|
-
nestedSensitiveFields: ['recipient'],
|
|
124
|
+
nestedSensitiveFields: ['recipient', 'responseObjects', 'correction', 'packetReceipt'],
|
|
114
125
|
audienceExclusions: {
|
|
115
126
|
trusted: { label: 'Recipient values', order: 2 },
|
|
116
127
|
public: { label: 'Actions and recipient values', order: 3 },
|
|
@@ -191,6 +202,7 @@ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
|
|
|
191
202
|
});
|
|
192
203
|
function currentAudienceExclusions(audience, caseVersion) {
|
|
193
204
|
const entries = [
|
|
205
|
+
...(audience === 'trusted' && caseVersion >= 19 ? [{ label: 'Private correction content and exact delivery receipts', order: 2 }] : []),
|
|
194
206
|
...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
|
|
195
207
|
OUTSIDE_SCHEMA_EXCLUSIONS[audience],
|
|
196
208
|
];
|
|
@@ -1,7 +1,29 @@
|
|
|
1
|
-
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
1
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, DELIVERY_CORRECTION_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
2
2
|
/** Adapt declared historical fields before ordinary current-record recovery. */
|
|
3
3
|
export function caseRecordVersionInput(record, sourceVersion) {
|
|
4
4
|
const declared = sourceVersion != null;
|
|
5
|
+
if (declared && sourceVersion < DELIVERY_CORRECTION_CASE_SCHEMA_VERSION && Array.isArray(record.actions)
|
|
6
|
+
&& record.actions.some(action => action && typeof action === 'object' && (action.correction !== undefined
|
|
7
|
+
|| Array.isArray(action.history) && action.history.some((event) => event?.packetReceipt !== undefined)))) {
|
|
8
|
+
throw new TypeError('Delivery receipts and corrections require Case schema 19; historical deliveries were not reinterpreted.');
|
|
9
|
+
}
|
|
10
|
+
if (declared && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION) {
|
|
11
|
+
const items = (value) => Array.isArray(value) ? value.filter((item) => !!item && typeof item === 'object' && !Array.isArray(item)) : [];
|
|
12
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
13
|
+
const pins = items(record.evidencePins);
|
|
14
|
+
const actions = items(record.actions);
|
|
15
|
+
const reviews = items(object(record.observedEffects).reviews);
|
|
16
|
+
const closures = items(object(record.closures).records);
|
|
17
|
+
const questions = [...items(record.assertions), ...reviews].map(item => object(item.recheck));
|
|
18
|
+
const events = actions.flatMap(action => items(action.history));
|
|
19
|
+
if (pins.some(pin => pin.responseObject !== undefined || pin.infrastructureObservation !== undefined)
|
|
20
|
+
|| actions.some(action => action.responseObjects !== undefined)
|
|
21
|
+
|| events.some(event => event.responseObjects !== undefined || event.objectOutcome !== undefined)
|
|
22
|
+
|| [...reviews, ...closures, ...questions].some(item => item.responseObject !== undefined || item.objectOutcome !== undefined)
|
|
23
|
+
|| [...items(record.evidenceHistory), object(record.evidence)].some(snapshot => snapshot.hasExternalPasswordForm !== undefined)) {
|
|
24
|
+
throw new TypeError('Object response and password-form attribution fields require Case schema 18; historical evidence was not reinterpreted.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
if (record.evidenceLinks !== undefined && declared && sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
6
28
|
throw new TypeError('Evidence relationships require the current Case schema.');
|
|
7
29
|
}
|
|
@@ -14,9 +14,13 @@ import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs
|
|
|
14
14
|
import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
|
|
15
15
|
import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
|
|
16
16
|
import { caseTypeSummary } from "./case-workflow-metadata.mjs";
|
|
17
|
+
import { caseClosureHistoryQualification } from "./case-response-outcomes.mjs";
|
|
17
18
|
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
|
|
18
19
|
import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
|
|
19
20
|
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
|
|
21
|
+
import { EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
22
|
+
import { OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
23
|
+
import { CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
|
|
20
24
|
import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
|
|
21
25
|
import { escapeCaseMarkdownInline as escapeMarkdownInline } from "./case-markdown.mjs";
|
|
22
26
|
import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
@@ -145,6 +149,7 @@ function pickKnownSnapshotFields(snapshot) {
|
|
|
145
149
|
reusesOfficialAssets: snapshot.reusesOfficialAssets,
|
|
146
150
|
hasPasswordField: snapshot.hasPasswordField,
|
|
147
151
|
hasExternalFormAction: snapshot.hasExternalFormAction,
|
|
152
|
+
...(snapshot.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: snapshot.hasExternalPasswordForm }),
|
|
148
153
|
phishingLanguageMatch: snapshot.phishingLanguageMatch,
|
|
149
154
|
privacyProtected: snapshot.privacyProtected ?? null,
|
|
150
155
|
idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
|
|
@@ -279,6 +284,10 @@ export function buildCaseReportVerificationProjection(caseRecord, options, schem
|
|
|
279
284
|
const generated = buildCaseReport(caseRecord, options).json;
|
|
280
285
|
if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
|
|
281
286
|
return generated;
|
|
287
|
+
if (schemaVersion === OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION)
|
|
288
|
+
return { ...generated, schemaVersion };
|
|
289
|
+
if (schemaVersion === EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION)
|
|
290
|
+
return { ...generated, schemaVersion };
|
|
282
291
|
if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
|
|
283
292
|
throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
|
|
284
293
|
}
|
|
@@ -499,6 +508,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
499
508
|
for (const pin of response.evidencePins) {
|
|
500
509
|
lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
|
|
501
510
|
lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt ?? 'Time unavailable')}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
|
|
511
|
+
if (pin.responseObject)
|
|
512
|
+
lines.push(` Exact object: ${escapeMarkdownInline(pin.responseObject.kind)} · ${escapeMarkdownInline(pin.responseObject.identifier)}`);
|
|
502
513
|
if (pin.limitations.length)
|
|
503
514
|
lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
|
|
504
515
|
}
|
|
@@ -587,6 +598,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
587
598
|
for (const action of response.actions) {
|
|
588
599
|
lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
|
|
589
600
|
lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
|
|
601
|
+
lines.push(` Object binding: ${action.responseObjects?.length ? action.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ') : 'Unknown; historical or unbound actions do not establish exact-object coverage.'}`);
|
|
590
602
|
if (action.routeObservedAt)
|
|
591
603
|
lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
|
|
592
604
|
if (action.routeReviewAfter)
|
|
@@ -595,6 +607,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
595
607
|
lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
|
|
596
608
|
if (action.amendment)
|
|
597
609
|
lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
|
|
610
|
+
if (action.correction)
|
|
611
|
+
lines.push(` ${action.correction.purpose === 'correction' ? 'Correction request' : 'Retraction request'} for delivery event ${escapeMarkdownInline(action.correction.deliveryEventId)}; packet v${action.correction.packetVersion} SHA-256: ${action.correction.packetDigestSha256}`, ` Reason: ${escapeMarkdownInline(action.correction.reason)}`, ` Previous statement: ${escapeMarkdownInline(action.correction.previousStatement)}`, ` Corrected statement: ${escapeMarkdownInline(action.correction.correctedStatement || 'Request to retract the previous statement')}`, ` Corrected evidence pins: ${action.correction.evidencePinIds.map(escapeMarkdownInline).join(', ')}`, ' Preparation is not delivery, recipient acceptance, restoration or independent recheck.');
|
|
598
612
|
for (const event of action.history)
|
|
599
613
|
if (event.evidenceRequest) {
|
|
600
614
|
const request = event.evidenceRequest;
|
|
@@ -616,8 +630,14 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
616
630
|
lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
|
|
617
631
|
if (event.providerOutcome)
|
|
618
632
|
lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
|
|
633
|
+
if (event.responseObjects?.length)
|
|
634
|
+
lines.push(` Objects at this event: ${event.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ')}`);
|
|
635
|
+
if (event.objectOutcome)
|
|
636
|
+
lines.push(` Source-reported object outcome: ${escapeMarkdownInline(event.objectOutcome)}; not independent verification or causation.`);
|
|
619
637
|
if (event.reference)
|
|
620
638
|
lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
|
|
639
|
+
if (event.packetReceipt)
|
|
640
|
+
lines.push(` Retained packet v${event.packetReceipt.packetVersion}: ${event.packetReceipt.packetDigestSha256}; recipient ${escapeMarkdownInline(event.packetReceipt.recipient)}; audience ${escapeMarkdownInline(event.packetReceipt.profile)}; generated ${event.packetReceipt.packetGeneratedAt}`, ` ${CASE_DELIVERY_RECEIPT_LIMITATION}`);
|
|
621
641
|
if (event.evidencePinId)
|
|
622
642
|
lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
|
|
623
643
|
if (event.limitations.length)
|
|
@@ -667,6 +687,10 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
667
687
|
lines.push('- No independent observed-effect review recorded.');
|
|
668
688
|
for (const review of response.observedEffects.reviews) {
|
|
669
689
|
lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
|
|
690
|
+
if (review.responseObject)
|
|
691
|
+
lines.push(` Exact object: ${escapeMarkdownInline(review.responseObject.kind)} · ${escapeMarkdownInline(review.responseObject.identifier)}`);
|
|
692
|
+
if (review.objectOutcome)
|
|
693
|
+
lines.push(` Independently recorded object outcome: ${escapeMarkdownInline(review.objectOutcome)}; point-in-time observation, not report causation or recurrence.`);
|
|
670
694
|
if (review.recheck) {
|
|
671
695
|
lines.push(` Question ${escapeMarkdownInline(review.recheck.questionId)}: ${escapeMarkdownInline(review.recheck.question)}`);
|
|
672
696
|
lines.push(` Recheck target: ${escapeMarkdownInline(review.recheck.targetHostname)}; retained comparison conditions: ${escapeMarkdownInline(review.recheck.conditions)}`);
|
|
@@ -689,13 +713,19 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
689
713
|
lines.push('- No deliberate analyst closure recorded.');
|
|
690
714
|
for (const closure of response.closures.records) {
|
|
691
715
|
lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
|
|
716
|
+
lines.push(` Scope: ${closure.responseObject ? `${escapeMarkdownInline(closure.responseObject.kind)} · ${escapeMarkdownInline(closure.responseObject.identifier)}; other objects and the Case remain independent.` : 'Whole Case analyst decision.'}`);
|
|
692
717
|
if (closure.observedEffectReviewId)
|
|
693
718
|
lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
|
|
694
719
|
if (closure.actionId)
|
|
695
720
|
lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
|
|
721
|
+
const qualification = caseClosureHistoryQualification(closure, response.actions);
|
|
722
|
+
if (qualification)
|
|
723
|
+
lines.push(` History qualification: ${escapeMarkdownInline(qualification)}`);
|
|
696
724
|
if (closure.limitations.length)
|
|
697
725
|
lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
|
|
698
726
|
}
|
|
727
|
+
if (response.closures.omitted)
|
|
728
|
+
lines.push(`- Closure records not retained: ${response.closures.omitted}. ${escapeMarkdownInline(response.closures.limitations.join('; '))}`);
|
|
699
729
|
lines.push('');
|
|
700
730
|
// Analyst notes (only when included)
|
|
701
731
|
if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
|
|
@@ -57,7 +57,7 @@ export function assertEvidenceRequestEvent(request, event) {
|
|
|
57
57
|
export function submittedPacketReceipts(action) {
|
|
58
58
|
return action.history.flatMap(event => {
|
|
59
59
|
const match = /^response-packet-sha256:([a-f0-9]{64})$/u.exec(event.reference ?? '');
|
|
60
|
-
return event.applied && event.previousState
|
|
60
|
+
return event.applied && ['authorised', 'submitted'].includes(event.previousState ?? '') && event.nextState === 'submitted'
|
|
61
61
|
&& event.sourceClass === 'analyst' && match
|
|
62
62
|
? [{ eventId: event.id, digestSha256: match[1], occurredAt: event.occurredAt }] : [];
|
|
63
63
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Append-only response actions: transitions, reconciliation and bounded history.
|
|
2
2
|
import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
|
|
3
|
+
import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
|
|
3
4
|
import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
|
|
4
5
|
import { emailRecipient } from "../evidence/email-recipient.mjs";
|
|
5
6
|
import { responseRouteFreshness } from "./response-route-freshness.mjs";
|
|
7
|
+
import { readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
6
8
|
import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
7
9
|
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
8
10
|
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
|
|
@@ -160,6 +162,12 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
160
162
|
if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
161
163
|
throw new Error('Requested evidence requires Case schema 17.');
|
|
162
164
|
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
165
|
+
if (item.packetReceipt !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
166
|
+
throw new TypeError('Delivery receipts require Case schema 19.');
|
|
167
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
168
|
+
assertDeliveryPacketReceipt(packetReceipt, { previousState, nextState, sourceClass, occurredAt,
|
|
169
|
+
reference: typeof item.reference === 'string' ? item.reference : null,
|
|
170
|
+
responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }, actionId);
|
|
163
171
|
assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
|
|
164
172
|
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
165
173
|
? item.providerOutcome
|
|
@@ -189,7 +197,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
189
197
|
: null;
|
|
190
198
|
const eventMaterial = {
|
|
191
199
|
previousState,
|
|
200
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
201
|
+
...(item.objectOutcome === undefined ? {} : { objectOutcome: readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion) }),
|
|
192
202
|
...(evidenceRequest ? { evidenceRequest } : {}),
|
|
203
|
+
...(packetReceipt ? { packetReceipt } : {}),
|
|
193
204
|
nextState,
|
|
194
205
|
occurredAt,
|
|
195
206
|
sourceClass,
|
|
@@ -206,6 +217,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
206
217
|
? item.originActionId
|
|
207
218
|
: null,
|
|
208
219
|
};
|
|
220
|
+
if (eventMaterial.objectOutcome) {
|
|
221
|
+
if (!['submitted', 'acknowledged', 'terminal'].includes(nextState) || previousState === 'authorised')
|
|
222
|
+
throw new TypeError('Object outcomes require a separate attributed event after submission.');
|
|
223
|
+
if (sourceClass !== 'provider' && sourceClass !== 'analyst')
|
|
224
|
+
throw new TypeError('Object outcomes require an attributed provider or analyst event.');
|
|
225
|
+
if (!eventMaterial.responseObjects?.length)
|
|
226
|
+
throw new TypeError('Select the objects concerned by this provider outcome; no complete-action resolution is inferred.');
|
|
227
|
+
for (const object of eventMaterial.responseObjects)
|
|
228
|
+
assertCaseObjectOutcome(eventMaterial.objectOutcome, object);
|
|
229
|
+
}
|
|
209
230
|
return {
|
|
210
231
|
id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
|
|
211
232
|
...eventMaterial,
|
|
@@ -373,6 +394,11 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
373
394
|
if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
374
395
|
throw new Error('Packet amendments require Case schema 17.');
|
|
375
396
|
const amendment = readCasePacketAmendment(item.amendment);
|
|
397
|
+
if (item.correction !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
398
|
+
throw new TypeError('Packet corrections require Case schema 19.');
|
|
399
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
400
|
+
if (correction && amendment)
|
|
401
|
+
throw new TypeError('Self-initiated correction and provider-request amendment are separate purposes.');
|
|
376
402
|
const applied = history.history.filter((event) => event.applied);
|
|
377
403
|
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
378
404
|
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
@@ -387,7 +413,9 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
387
413
|
: null;
|
|
388
414
|
return {
|
|
389
415
|
id: actionId,
|
|
416
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
390
417
|
...(amendment ? { amendment } : {}),
|
|
418
|
+
...(correction ? { correction } : {}),
|
|
391
419
|
type: legacyPlatformReview
|
|
392
420
|
? 'platform_report'
|
|
393
421
|
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
@@ -575,9 +603,14 @@ export function appendCaseAction(current, raw, now) {
|
|
|
575
603
|
const amendment = readCasePacketAmendment(item.amendment);
|
|
576
604
|
if (amendment)
|
|
577
605
|
assertPacketAmendment(current, originActionId, amendment);
|
|
606
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
607
|
+
if (correction)
|
|
608
|
+
correctionDelivery(current, { originActionId, correction,
|
|
609
|
+
recipient: text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH), responseObjects: readCaseResponseObjects(item.responseObjects) });
|
|
578
610
|
const history = [{
|
|
579
611
|
id: freshId('action-event'),
|
|
580
612
|
previousState: null,
|
|
613
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects) }),
|
|
581
614
|
nextState: 'drafting',
|
|
582
615
|
occurredAt: now,
|
|
583
616
|
sourceClass: 'analyst',
|
|
@@ -602,11 +635,22 @@ export function appendCaseAction(current, raw, now) {
|
|
|
602
635
|
throw new Error('An action requires a recipient or internal owner.');
|
|
603
636
|
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
604
637
|
}
|
|
638
|
+
export function providerOutcomeRequiresObjectScope(outcome) {
|
|
639
|
+
return outcome === 'provider_reports_resolved' || outcome === 'partially_remediated';
|
|
640
|
+
}
|
|
605
641
|
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
606
642
|
const action = current.find((item) => item.id === actionId);
|
|
607
643
|
if (!action)
|
|
608
644
|
throw new Error('That case action no longer exists.');
|
|
609
645
|
const item = record(raw);
|
|
646
|
+
const selectedObjects = readCaseResponseObjects(item.responseObjects);
|
|
647
|
+
const outcome = readCaseResponseObjectOutcome(item.objectOutcome);
|
|
648
|
+
if (selectedObjects?.some(object => !action.responseObjects?.some(bound => sameCaseResponseObject(bound, object))))
|
|
649
|
+
throw new TypeError('A transition may concern only objects explicitly bound to its action.');
|
|
650
|
+
if (outcome && !selectedObjects?.length)
|
|
651
|
+
throw new TypeError('Explicitly select the affected objects for this outcome; it is not applied to every action object.');
|
|
652
|
+
if (action.responseObjects?.length && providerOutcomeRequiresObjectScope(item.providerOutcome) && !selectedObjects?.length)
|
|
653
|
+
throw new TypeError('Explicitly select the objects covered by this provider-reported outcome; other action objects remain unchecked.');
|
|
610
654
|
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
611
655
|
? item.nextState
|
|
612
656
|
: null;
|
|
@@ -637,9 +681,18 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
637
681
|
if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
|
|
638
682
|
assertPacketAmendment(current, action.originActionId, action.amendment);
|
|
639
683
|
}
|
|
684
|
+
if (action.correction && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState))
|
|
685
|
+
correctionDelivery(current, action);
|
|
686
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
687
|
+
if (packetReceipt && (packetReceipt.recipient !== action.recipient
|
|
688
|
+
|| JSON.stringify(packetReceipt.responseObjects) !== JSON.stringify(action.responseObjects ?? [])))
|
|
689
|
+
throw new TypeError('The delivered packet receipt no longer matches this action recipient or scope.');
|
|
690
|
+
if (action.correction && nextState === 'submitted' && !packetReceipt)
|
|
691
|
+
throw new TypeError('Record the separately prepared correction packet receipt only after actual delivery.');
|
|
640
692
|
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
641
693
|
const event = normalizeActionEvent({
|
|
642
694
|
...item,
|
|
695
|
+
...(selectedObjects !== undefined ? { responseObjects: selectedObjects } : action.responseObjects === undefined ? {} : { responseObjects: action.responseObjects }),
|
|
643
696
|
id: freshId('action-event'),
|
|
644
697
|
previousState: action.state,
|
|
645
698
|
nextState,
|
|
@@ -658,7 +711,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
658
711
|
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
659
712
|
}
|
|
660
713
|
const ACTION_REVIEW_MATERIAL_FIELDS = [
|
|
661
|
-
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
|
|
714
|
+
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment', 'correction', 'responseObjects',
|
|
662
715
|
];
|
|
663
716
|
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
664
717
|
const patch = record(raw);
|
|
@@ -689,6 +742,8 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
689
742
|
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
690
743
|
if (materialChanged && updated.amendment)
|
|
691
744
|
assertPacketAmendment(current, updated.originActionId, updated.amendment);
|
|
745
|
+
if (materialChanged && updated.correction)
|
|
746
|
+
correctionDelivery(current, updated);
|
|
692
747
|
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
693
748
|
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
694
749
|
}
|
|
@@ -702,6 +757,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
702
757
|
occurredAt: now,
|
|
703
758
|
sourceClass: 'browser_local',
|
|
704
759
|
provenance: 'material_action_change',
|
|
760
|
+
...(updated.responseObjects === undefined ? {} : { responseObjects: updated.responseObjects }),
|
|
705
761
|
reference: null,
|
|
706
762
|
evidencePinId: null,
|
|
707
763
|
limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
|
|
@@ -7,9 +7,12 @@ import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS,
|
|
|
7
7
|
import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
|
|
8
8
|
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
9
9
|
import { readCaseRecheckContext } from "./case-recheck-model.mjs";
|
|
10
|
+
import { readCaseResponseObject } from "./case-response-object.mjs";
|
|
11
|
+
import { normalizeInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
|
|
10
12
|
export * from "./case-response-records.mjs";
|
|
11
13
|
export * from "./case-response-actions.mjs";
|
|
12
14
|
export * from "./case-response-outcomes.mjs";
|
|
15
|
+
export * from "./case-response-object.mjs";
|
|
13
16
|
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, } from "../contracts/case-portability.mjs";
|
|
14
17
|
const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
|
|
15
18
|
const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
|
|
@@ -108,6 +111,8 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
|
108
111
|
}
|
|
109
112
|
function normalizePin(raw, fallback, options = {}) {
|
|
110
113
|
const item = record(raw);
|
|
114
|
+
if (item.infrastructureObservation !== undefined && options.sourceVersion != null && options.sourceVersion < 18)
|
|
115
|
+
throw new TypeError('Infrastructure observations require Case schema 18 or later.');
|
|
111
116
|
const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
|
|
112
117
|
const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
|
|
113
118
|
if (!label || !value)
|
|
@@ -141,6 +146,8 @@ function normalizePin(raw, fallback, options = {}) {
|
|
|
141
146
|
: null,
|
|
142
147
|
limitations: limitations(item.limitations),
|
|
143
148
|
createdAt,
|
|
149
|
+
...(item.responseObject === undefined ? {} : { responseObject: readCaseResponseObject(item.responseObject, options.sourceVersion) }),
|
|
150
|
+
...(item.infrastructureObservation === undefined ? {} : { infrastructureObservation: normalizeInfrastructureObservation(item.infrastructureObservation) }),
|
|
144
151
|
};
|
|
145
152
|
normalized.certificateObservation = options.allowCertificateObservation === false
|
|
146
153
|
? null
|