@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +12 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +60 -11
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +6 -4
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +171 -51
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +3 -2
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
|
|
2
2
|
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
|
|
3
|
+
import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
|
|
3
4
|
export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
|
|
4
|
-
export
|
|
5
|
-
{ value: 'new', label: 'New' },
|
|
6
|
-
{ value: 'reviewing', label: 'Reviewing' },
|
|
7
|
-
{ value: 'monitoring', label: 'Monitoring' },
|
|
8
|
-
{ value: 'escalated', label: 'Escalated' },
|
|
9
|
-
{ value: 'resolved', label: 'Resolved' },
|
|
10
|
-
];
|
|
11
|
-
export const CASE_DISPOSITIONS = [
|
|
12
|
-
{ value: 'unreviewed', label: 'Unreviewed' },
|
|
13
|
-
{ value: 'suspicious', label: 'Suspicious' },
|
|
14
|
-
{ value: 'confirmed_abuse', label: 'Confirmed abuse' },
|
|
15
|
-
{ value: 'false_positive', label: 'False positive' },
|
|
16
|
-
{ value: 'expected', label: 'Expected' },
|
|
17
|
-
{ value: 'closed_no_action', label: 'Closed without action' },
|
|
18
|
-
];
|
|
5
|
+
export { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, };
|
|
19
6
|
export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
|
|
20
|
-
export const CASE_SOURCES = [
|
|
7
|
+
export const CASE_SOURCES = Object.freeze([
|
|
21
8
|
{ value: 'lookup', label: 'Lookup' },
|
|
22
9
|
{ value: 'bulk', label: 'Bulk' },
|
|
23
10
|
{ value: 'monitor', label: 'Monitor' },
|
|
24
11
|
{ value: 'manual', label: 'Manual' },
|
|
25
12
|
{ value: 'unknown', label: 'Unknown' },
|
|
26
|
-
];
|
|
27
|
-
export const EVIDENCE_SOURCES = ['lookup', 'bulk', 'monitor', 'import', 'unknown'];
|
|
28
|
-
export const DEFAULT_STATUS = 'new';
|
|
29
|
-
export const DEFAULT_DISPOSITION = 'unreviewed';
|
|
13
|
+
]);
|
|
14
|
+
export const EVIDENCE_SOURCES = Object.freeze(['lookup', 'bulk', 'monitor', 'import', 'unknown']);
|
|
30
15
|
export const DEFAULT_SOURCE = 'unknown';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import {
|
|
3
|
+
import { CASE_SCHEMA_VERSION, 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
|
+
import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
|
|
4
5
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
6
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
6
7
|
// Forward-version policy (two distinct guarantees):
|
|
@@ -26,13 +27,9 @@ export const EVIDENCE_SOURCE_RANK = {
|
|
|
26
27
|
import: 1,
|
|
27
28
|
unknown: 0,
|
|
28
29
|
};
|
|
29
|
-
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
30
|
-
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
31
30
|
const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
|
|
32
31
|
const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
|
|
33
|
-
const
|
|
34
|
-
const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
|
|
35
|
-
const SOURCE_LABELS = Object.fromEntries(CASE_SOURCES.map((item) => [item.value, item.label]));
|
|
32
|
+
const SOURCE_LABELS = new Map(CASE_SOURCES.map((item) => [item.value, item.label]));
|
|
36
33
|
// Availability tokens that actually assert something about the domain. Anything
|
|
37
34
|
// else ('unknown', 'error', empty) is not, on its own, material evidence.
|
|
38
35
|
export const CONCLUSIVE_AVAILABILITY = new Set([
|
|
@@ -59,21 +56,13 @@ export function objectRecord(value) {
|
|
|
59
56
|
? value
|
|
60
57
|
: {};
|
|
61
58
|
}
|
|
62
|
-
export function statusLabel(value) {
|
|
63
|
-
return (typeof value === 'string' ? STATUS_LABELS[value] : '') || String(value || '');
|
|
64
|
-
}
|
|
65
|
-
export function dispositionLabel(value) {
|
|
66
|
-
return (typeof value === 'string' ? DISPOSITION_LABELS[value] : '') || String(value || '');
|
|
67
|
-
}
|
|
68
59
|
export function sourceLabel(value) {
|
|
69
|
-
return (
|
|
70
|
-
}
|
|
71
|
-
export function isValidStatus(value) {
|
|
72
|
-
return typeof value === 'string' && STATUS_VALUES.has(value);
|
|
60
|
+
return isValidSource(value) ? SOURCE_LABELS.get(value) : String(value || '');
|
|
73
61
|
}
|
|
74
|
-
export function
|
|
75
|
-
return typeof value === 'string' &&
|
|
62
|
+
export function isValidSource(value) {
|
|
63
|
+
return typeof value === 'string' && SOURCE_VALUES.has(value);
|
|
76
64
|
}
|
|
65
|
+
export { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, };
|
|
77
66
|
/** Fresh, safe, effectively-unique id for a brand-new local record. */
|
|
78
67
|
export function makeId() {
|
|
79
68
|
if (typeof globalThis.crypto?.randomUUID === 'function')
|
|
@@ -147,16 +136,16 @@ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
|
|
|
147
136
|
return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
|
|
148
137
|
}
|
|
149
138
|
export function normalizeStatus(value) {
|
|
150
|
-
return
|
|
139
|
+
return isValidStatus(value) ? value : DEFAULT_STATUS;
|
|
151
140
|
}
|
|
152
141
|
export function normalizeDisposition(value) {
|
|
153
|
-
return
|
|
142
|
+
return isValidDisposition(value) ? value : DEFAULT_DISPOSITION;
|
|
154
143
|
}
|
|
155
144
|
export function normalizeReviewReasonCode(value) {
|
|
156
145
|
return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
|
|
157
146
|
}
|
|
158
147
|
export function normalizeSource(value) {
|
|
159
|
-
return
|
|
148
|
+
return isValidSource(value) ? value : DEFAULT_SOURCE;
|
|
160
149
|
}
|
|
161
150
|
/** Parsed ISO string, or null when missing/invalid (used for import ordering). */
|
|
162
151
|
export function isoOrNull(value) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Lightweight canonical Case status and disposition definitions. Persistence,
|
|
2
|
+
// portability and presentation consumers derive these identities and policies
|
|
3
|
+
// without importing the wider Case record or serialisation graph.
|
|
4
|
+
export const CASE_STATUSES = Object.freeze([
|
|
5
|
+
{ value: 'new', label: 'New' },
|
|
6
|
+
{ value: 'reviewing', label: 'Reviewing' },
|
|
7
|
+
{ value: 'monitoring', label: 'Monitoring' },
|
|
8
|
+
{ value: 'escalated', label: 'Escalated' },
|
|
9
|
+
{ value: 'resolved', label: 'Resolved' },
|
|
10
|
+
]);
|
|
11
|
+
export const CASE_DISPOSITIONS = Object.freeze([
|
|
12
|
+
{ value: 'unreviewed', label: 'Unreviewed' },
|
|
13
|
+
{ value: 'suspicious', label: 'Suspicious' },
|
|
14
|
+
{ value: 'confirmed_abuse', label: 'Confirmed abuse' },
|
|
15
|
+
{ value: 'false_positive', label: 'False positive' },
|
|
16
|
+
{ value: 'expected', label: 'Expected' },
|
|
17
|
+
{ value: 'closed_no_action', label: 'Closed without action' },
|
|
18
|
+
]);
|
|
19
|
+
export const DEFAULT_STATUS = 'new';
|
|
20
|
+
export const DEFAULT_DISPOSITION = 'unreviewed';
|
|
21
|
+
const CASE_STATUS_OPERATION_POLICIES = Object.freeze({
|
|
22
|
+
new: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
23
|
+
reviewing: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
24
|
+
monitoring: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
25
|
+
escalated: { closed: false, closureRequired: false, directEdit: 'allowed' },
|
|
26
|
+
resolved: { closed: true, closureRequired: true, directEdit: 'existing_only' },
|
|
27
|
+
});
|
|
28
|
+
const CASE_DISPOSITION_DECISION_POLICIES = Object.freeze({
|
|
29
|
+
unreviewed: { reviewed: false, supportsDefensiveResponse: false },
|
|
30
|
+
suspicious: { reviewed: true, supportsDefensiveResponse: true },
|
|
31
|
+
confirmed_abuse: { reviewed: true, supportsDefensiveResponse: true },
|
|
32
|
+
false_positive: { reviewed: true, supportsDefensiveResponse: false },
|
|
33
|
+
expected: { reviewed: true, supportsDefensiveResponse: false },
|
|
34
|
+
closed_no_action: { reviewed: true, supportsDefensiveResponse: false },
|
|
35
|
+
});
|
|
36
|
+
function statusOption(value) {
|
|
37
|
+
return CASE_STATUSES.find((option) => option.value === value);
|
|
38
|
+
}
|
|
39
|
+
function dispositionOption(value) {
|
|
40
|
+
return CASE_DISPOSITIONS.find((option) => option.value === value);
|
|
41
|
+
}
|
|
42
|
+
export function isValidStatus(value) {
|
|
43
|
+
return Boolean(statusOption(value));
|
|
44
|
+
}
|
|
45
|
+
export function isValidDisposition(value) {
|
|
46
|
+
return Boolean(dispositionOption(value));
|
|
47
|
+
}
|
|
48
|
+
export function statusLabel(value) {
|
|
49
|
+
return statusOption(value)?.label ?? String(value || '');
|
|
50
|
+
}
|
|
51
|
+
export function dispositionLabel(value) {
|
|
52
|
+
return dispositionOption(value)?.label ?? String(value || '');
|
|
53
|
+
}
|
|
54
|
+
export function caseStatusRequiresClosure(value) {
|
|
55
|
+
const option = statusOption(value);
|
|
56
|
+
return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closureRequired : false;
|
|
57
|
+
}
|
|
58
|
+
export function caseStatusIsClosed(value) {
|
|
59
|
+
const option = statusOption(value);
|
|
60
|
+
return option ? CASE_STATUS_OPERATION_POLICIES[option.value].closed : false;
|
|
61
|
+
}
|
|
62
|
+
export function isReviewedCaseDisposition(value) {
|
|
63
|
+
const option = dispositionOption(value);
|
|
64
|
+
return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].reviewed : false;
|
|
65
|
+
}
|
|
66
|
+
export function caseDispositionSupportsDefensiveResponse(value) {
|
|
67
|
+
const option = dispositionOption(value);
|
|
68
|
+
return option ? CASE_DISPOSITION_DECISION_POLICIES[option.value].supportsDefensiveResponse : false;
|
|
69
|
+
}
|
|
70
|
+
export function caseStatusOptionsForDirectEdit(currentStatus) {
|
|
71
|
+
return CASE_STATUSES.filter((option) => (CASE_STATUS_OPERATION_POLICIES[option.value].directEdit === 'allowed' || option.value === currentStatus));
|
|
72
|
+
}
|
|
@@ -6,4 +6,5 @@ export * from "./case-record-core.mjs";
|
|
|
6
6
|
export * from "./case-brand-profile-references.mjs";
|
|
7
7
|
export * from "./case-evidence-model.mjs";
|
|
8
8
|
export * from "./case-record-operations.mjs";
|
|
9
|
+
export * from "./case-record-projection.mjs";
|
|
9
10
|
export * from "./case-investigation-branch-model.mjs";
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
4
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";
|
|
5
5
|
import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
6
|
+
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
6
7
|
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";
|
|
7
8
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
8
9
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
9
10
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
11
|
+
export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
|
|
10
12
|
export const MAX_CASE_OBJECTIVE_LENGTH = 320;
|
|
11
13
|
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
12
14
|
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
@@ -156,7 +158,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
156
158
|
return {
|
|
157
159
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
158
160
|
domain,
|
|
159
|
-
status: normalizedStatus
|
|
161
|
+
status: caseStatusRequiresClosure(normalizedStatus)
|
|
160
162
|
&& closures.records.length === 0 && !closures.preV13HistoryUnavailable
|
|
161
163
|
? 'reviewing'
|
|
162
164
|
: normalizedStatus,
|
|
@@ -214,7 +216,7 @@ export function createCase(input, nowIso) {
|
|
|
214
216
|
const closures = input.closure !== undefined
|
|
215
217
|
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
216
218
|
: normalizeCaseClosureHistory(undefined, now);
|
|
217
|
-
if (normalizeStatus(input.status)
|
|
219
|
+
if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
|
|
218
220
|
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
219
221
|
}
|
|
220
222
|
return {
|
|
@@ -356,7 +358,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
356
358
|
if (patch.closure !== undefined) {
|
|
357
359
|
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
358
360
|
}
|
|
359
|
-
if (patch.status
|
|
361
|
+
if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
|
|
360
362
|
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
361
363
|
}
|
|
362
364
|
const record = {
|
|
@@ -401,7 +403,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
|
401
403
|
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
402
404
|
const disposition = normalizeDisposition(input.disposition);
|
|
403
405
|
const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
|
|
404
|
-
if (disposition
|
|
406
|
+
if (!isReviewedCaseDisposition(disposition)) {
|
|
405
407
|
throw new Error('Select a reviewed disposition before recording a conclusion.');
|
|
406
408
|
}
|
|
407
409
|
if (!reviewReasonCode) {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { PUBLIC_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
2
|
+
import { caseStatusIsClosed } from "./case-record-decisions.mjs";
|
|
3
|
+
const PRESERVE = Object.freeze({
|
|
4
|
+
durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
|
|
5
|
+
});
|
|
6
|
+
const PUBLIC_TRANSFORM = Object.freeze({ ...PRESERVE, public: 'transform' });
|
|
7
|
+
const PUBLIC_EXCLUDE = Object.freeze({ ...PRESERVE, public: 'exclude' });
|
|
8
|
+
const SHARED_EXCLUDE = Object.freeze({ ...PRESERVE, trusted: 'exclude', public: 'exclude' });
|
|
9
|
+
const SHARED_REDACT = Object.freeze({ ...PRESERVE, trusted: 'redact', public: 'redact' });
|
|
10
|
+
const TRUSTED_REDACT_PUBLIC_EXCLUDE = Object.freeze({
|
|
11
|
+
...PRESERVE, trusted: 'redact', public: 'exclude',
|
|
12
|
+
});
|
|
13
|
+
const PUBLIC_REDACT = Object.freeze({ ...PRESERVE, public: 'redact' });
|
|
14
|
+
function fieldRule(key, treatment, value, options = {}) {
|
|
15
|
+
const audienceExclusions = Object.freeze(Object.fromEntries(Object.entries(options.audienceExclusions ?? {}).map(([audience, exclusion]) => [
|
|
16
|
+
audience,
|
|
17
|
+
Object.freeze({ ...exclusion }),
|
|
18
|
+
])));
|
|
19
|
+
for (const audience of ['internal', 'trusted', 'public']) {
|
|
20
|
+
const requiresExplanation = treatment[audience] === 'exclude' || treatment[audience] === 'redact';
|
|
21
|
+
if (requiresExplanation !== Boolean(audienceExclusions[audience])) {
|
|
22
|
+
throw new TypeError(`Case field ${key} must explain every redacted or excluded ${audience} treatment exactly once.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
key,
|
|
27
|
+
treatment,
|
|
28
|
+
value,
|
|
29
|
+
nestedSensitiveFields: Object.freeze([...(options.nestedSensitiveFields ?? [])]),
|
|
30
|
+
audienceExclusions,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function preservedField(key) {
|
|
34
|
+
return fieldRule(key, PRESERVE, (record) => record[key]);
|
|
35
|
+
}
|
|
36
|
+
const PUBLIC_OBSERVED_EFFECT_LIMITATION = 'Independent observed-effect review records were excluded from this public Case pack.';
|
|
37
|
+
const PUBLIC_CLOSURE_LIMITATION = 'Deliberate closure records were excluded from this public Case pack.';
|
|
38
|
+
function publicObservedEffectHistory(preV13HistoryUnavailable) {
|
|
39
|
+
return {
|
|
40
|
+
reviews: [],
|
|
41
|
+
omitted: 0,
|
|
42
|
+
preV13HistoryUnavailable,
|
|
43
|
+
limitations: [
|
|
44
|
+
...(preV13HistoryUnavailable
|
|
45
|
+
? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.']
|
|
46
|
+
: []),
|
|
47
|
+
'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
|
|
48
|
+
PUBLIC_OBSERVED_EFFECT_LIMITATION,
|
|
49
|
+
].sort(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function publicClosureHistory(preV13HistoryUnavailable) {
|
|
53
|
+
return {
|
|
54
|
+
records: [],
|
|
55
|
+
omitted: 0,
|
|
56
|
+
preV13HistoryUnavailable,
|
|
57
|
+
limitations: [
|
|
58
|
+
...(preV13HistoryUnavailable
|
|
59
|
+
? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.']
|
|
60
|
+
: []),
|
|
61
|
+
'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
|
|
62
|
+
PUBLIC_CLOSURE_LIMITATION,
|
|
63
|
+
].sort(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The complete current Case field policy. Browser persistence and ordinary
|
|
68
|
+
* exports deliberately share the `durable` projection. Case-pack audiences
|
|
69
|
+
* must classify every field here before a new Case property can compile.
|
|
70
|
+
*/
|
|
71
|
+
const CASE_FIELD_RULES = Object.freeze({
|
|
72
|
+
id: preservedField('id'),
|
|
73
|
+
domain: preservedField('domain'),
|
|
74
|
+
status: fieldRule('status', PUBLIC_TRANSFORM, (record, profile) => (profile === 'public' && caseStatusIsClosed(record.status) && record.closures.records.length
|
|
75
|
+
? 'reviewing'
|
|
76
|
+
: record.status)),
|
|
77
|
+
disposition: preservedField('disposition'),
|
|
78
|
+
reviewReasonCode: fieldRule('reviewReasonCode', PRESERVE, (record) => record.reviewReasonCode ?? null),
|
|
79
|
+
brandProfileIds: fieldRule('brandProfileIds', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : [...record.brandProfileIds]), {
|
|
80
|
+
audienceExclusions: { public: { label: 'Brand Profile references', order: 2 } },
|
|
81
|
+
}),
|
|
82
|
+
tags: fieldRule('tags', PRESERVE, (record) => [...record.tags]),
|
|
83
|
+
notes: fieldRule('notes', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public'
|
|
84
|
+
? []
|
|
85
|
+
: record.notes.map((item) => ({ ...item }))), {
|
|
86
|
+
audienceExclusions: {
|
|
87
|
+
trusted: { label: 'Case notes', order: 1 },
|
|
88
|
+
public: { label: 'Case notes', order: 1 },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
source: preservedField('source'),
|
|
92
|
+
evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
|
|
93
|
+
evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
|
|
94
|
+
decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
|
|
95
|
+
actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
|
|
96
|
+
? []
|
|
97
|
+
: profile === 'trusted'
|
|
98
|
+
? structuredClone(record.actions).map((item) => ({ ...item, recipient: '[redacted]' }))
|
|
99
|
+
: structuredClone(record.actions)), {
|
|
100
|
+
nestedSensitiveFields: ['recipient'],
|
|
101
|
+
audienceExclusions: {
|
|
102
|
+
trusted: { label: 'Recipient values', order: 2 },
|
|
103
|
+
public: { label: 'Actions and recipient values', order: 3 },
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
assertions: fieldRule('assertions', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.assertions)), {
|
|
107
|
+
audienceExclusions: { public: { label: 'Analyst assertions', order: 4 } },
|
|
108
|
+
}),
|
|
109
|
+
manualTrail: fieldRule('manualTrail', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
|
|
110
|
+
? structuredClone(record.manualTrail).map((item) => ({ ...item, target: null }))
|
|
111
|
+
: structuredClone(record.manualTrail)), {
|
|
112
|
+
nestedSensitiveFields: ['target'],
|
|
113
|
+
audienceExclusions: {
|
|
114
|
+
trusted: { label: 'Manual trail targets', order: 3 },
|
|
115
|
+
public: { label: 'Manual trail targets', order: 6 },
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
sightings: fieldRule('sightings', PRESERVE, (record) => structuredClone(record.sightings)),
|
|
119
|
+
observedEffects: fieldRule('observedEffects', PUBLIC_REDACT, (record, profile) => (profile === 'public'
|
|
120
|
+
? publicObservedEffectHistory(record.observedEffects.preV13HistoryUnavailable)
|
|
121
|
+
: structuredClone(record.observedEffects)), {
|
|
122
|
+
audienceExclusions: {
|
|
123
|
+
public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
closures: fieldRule('closures', PUBLIC_REDACT, (record, profile) => (profile === 'public'
|
|
127
|
+
? publicClosureHistory(record.closures.preV13HistoryUnavailable)
|
|
128
|
+
: structuredClone(record.closures)), {
|
|
129
|
+
audienceExclusions: {
|
|
130
|
+
public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
branches: fieldRule('branches', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.branches ?? [])), {
|
|
134
|
+
audienceExclusions: { public: { label: 'Investigation branches', order: 5 } },
|
|
135
|
+
}),
|
|
136
|
+
createdAt: preservedField('createdAt'),
|
|
137
|
+
updatedAt: preservedField('updatedAt'),
|
|
138
|
+
});
|
|
139
|
+
function projectCase(record, profile) {
|
|
140
|
+
const projected = {};
|
|
141
|
+
for (const key of Object.keys(CASE_FIELD_RULES)) {
|
|
142
|
+
const rule = CASE_FIELD_RULES[key];
|
|
143
|
+
Object.defineProperty(projected, key, {
|
|
144
|
+
configurable: true,
|
|
145
|
+
enumerable: true,
|
|
146
|
+
value: rule.value(record, profile),
|
|
147
|
+
writable: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return projected;
|
|
151
|
+
}
|
|
152
|
+
export function projectCaseForDurableWrite(record) {
|
|
153
|
+
return projectCase(record, 'durable');
|
|
154
|
+
}
|
|
155
|
+
export function projectCaseForAudience(record, audience) {
|
|
156
|
+
return projectCase(record, audience);
|
|
157
|
+
}
|
|
158
|
+
/** Field names used by the bounded hidden-copy defence, derived from policy. */
|
|
159
|
+
export const CASE_AUDIENCE_SENSITIVE_FIELD_NAMES = Object.freeze([...new Set(Object.entries(CASE_FIELD_RULES).flatMap(([key, rule]) => {
|
|
160
|
+
const audienceTreatments = [rule.treatment.trusted, rule.treatment.public];
|
|
161
|
+
const sensitive = audienceTreatments.some((value) => value === 'exclude' || value === 'redact');
|
|
162
|
+
return sensitive ? [key, ...rule.nestedSensitiveFields] : [];
|
|
163
|
+
}))]);
|
|
164
|
+
export const CASE_RECORD_FIELD_NAMES = Object.freeze(Object.values(CASE_FIELD_RULES).map((rule) => rule.key));
|
|
165
|
+
const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
|
|
166
|
+
internal: Object.freeze({ label: 'Raw upstream payloads and credentials are outside the case schema.', order: 1 }),
|
|
167
|
+
trusted: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 4 }),
|
|
168
|
+
public: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 7 }),
|
|
169
|
+
});
|
|
170
|
+
function currentAudienceExclusions(audience) {
|
|
171
|
+
const entries = [
|
|
172
|
+
...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
|
|
173
|
+
OUTSIDE_SCHEMA_EXCLUSIONS[audience],
|
|
174
|
+
];
|
|
175
|
+
const orderByLabel = new Map();
|
|
176
|
+
const labelByOrder = new Map();
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (!entry.label || entry.label.length > 160 || entry.label.trim() !== entry.label
|
|
179
|
+
|| !Number.isSafeInteger(entry.order) || entry.order < 1 || entry.order > 64) {
|
|
180
|
+
throw new TypeError('Case audience exclusion metadata is malformed or exceeds its bound.');
|
|
181
|
+
}
|
|
182
|
+
const prior = orderByLabel.get(entry.label);
|
|
183
|
+
if (prior !== undefined && prior !== entry.order) {
|
|
184
|
+
throw new TypeError(`Case audience exclusion ${entry.label} has conflicting presentation order.`);
|
|
185
|
+
}
|
|
186
|
+
const priorLabel = labelByOrder.get(entry.order);
|
|
187
|
+
if (priorLabel !== undefined && priorLabel !== entry.label) {
|
|
188
|
+
throw new TypeError(`Case audience exclusions ${priorLabel} and ${entry.label} share a presentation order.`);
|
|
189
|
+
}
|
|
190
|
+
orderByLabel.set(entry.label, entry.order);
|
|
191
|
+
labelByOrder.set(entry.order, entry.label);
|
|
192
|
+
}
|
|
193
|
+
return Object.freeze([...orderByLabel]
|
|
194
|
+
.sort((left, right) => left[1] - right[1])
|
|
195
|
+
.map(([label]) => label));
|
|
196
|
+
}
|
|
197
|
+
const CURRENT_AUDIENCE_EXCLUSIONS = Object.freeze({
|
|
198
|
+
internal: currentAudienceExclusions('internal'),
|
|
199
|
+
trusted: currentAudienceExclusions('trusted'),
|
|
200
|
+
public: currentAudienceExclusions('public'),
|
|
201
|
+
});
|
|
202
|
+
// Schema 12 is an immutable public compatibility contract and deliberately
|
|
203
|
+
// remains independent of the current field-policy projection.
|
|
204
|
+
const PUBLIC_V12_EXCLUSIONS = Object.freeze([
|
|
205
|
+
'Case notes',
|
|
206
|
+
'Brand Profile references',
|
|
207
|
+
'Actions and recipient values',
|
|
208
|
+
'Analyst assertions',
|
|
209
|
+
'Investigation branches',
|
|
210
|
+
'Manual trail targets',
|
|
211
|
+
'Raw upstream payloads and credentials',
|
|
212
|
+
]);
|
|
213
|
+
export function caseAudienceExclusions(audience, caseVersion) {
|
|
214
|
+
return audience === 'public' && caseVersion === PUBLIC_CASE_SCHEMA_VERSION
|
|
215
|
+
? PUBLIC_V12_EXCLUSIONS
|
|
216
|
+
: CURRENT_AUDIENCE_EXCLUSIONS[audience];
|
|
217
|
+
}
|
|
218
|
+
export { CASE_FIELD_RULES };
|
|
@@ -182,10 +182,9 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
182
182
|
if (!previous)
|
|
183
183
|
continue;
|
|
184
184
|
const rawChanges = compareCaseEvidence(previous, snapshot);
|
|
185
|
-
|
|
186
|
-
const hostnameContextChanged =
|
|
187
|
-
|
|
188
|
-
if (rawChanges.length > 0) {
|
|
185
|
+
incomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
|
|
186
|
+
const hostnameContextChanged = incomparableReasons.includes('observation-context');
|
|
187
|
+
if (rawChanges.length > 0 && !hostnameContextChanged) {
|
|
189
188
|
changes = rawChanges.map((change) => ({
|
|
190
189
|
field: change.field,
|
|
191
190
|
label: change.label,
|
|
@@ -195,8 +194,7 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
195
194
|
}));
|
|
196
195
|
}
|
|
197
196
|
else if (snapshot.fingerprint !== previous.fingerprint
|
|
198
|
-
&& incomparableReasons.length === 0
|
|
199
|
-
&& !hostnameContextChanged) {
|
|
197
|
+
&& incomparableReasons.length === 0) {
|
|
200
198
|
incomparableReasons = ['other'];
|
|
201
199
|
}
|
|
202
200
|
hasIncomparableChange = incomparableReasons.length > 0;
|
|
@@ -452,6 +450,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
452
450
|
}
|
|
453
451
|
if (entry.hasIncomparableChange) {
|
|
454
452
|
const reasons = Array.isArray(entry.incomparableReasons) ? entry.incomparableReasons : [];
|
|
453
|
+
if (reasons.includes('observation-context'))
|
|
454
|
+
lines.push('> Observation targets differ or are unknown. Field-level additions, removals and beneficial changes are withheld because their scopes cannot be compared reliably. Exact hostnames remain excluded from this report.');
|
|
455
455
|
if (reasons.includes('risk-model'))
|
|
456
456
|
lines.push('> Risk scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
|
|
457
457
|
if (reasons.includes('opportunity-model'))
|
|
@@ -544,6 +544,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
544
544
|
for (const decision of response.decisions) {
|
|
545
545
|
lines.push(`- **${escapeMarkdownInline(decision.summary)}** (${escapeMarkdownInline(decision.createdAt)})`);
|
|
546
546
|
lines.push(` ${escapeMarkdownInline(decision.rationale)}`);
|
|
547
|
+
lines.push(` Analyst confidence: ${escapeMarkdownInline(decision.confidence)}${decision.confidenceBasis ? ` — ${escapeMarkdownInline(decision.confidenceBasis)}` : ''}.`);
|
|
547
548
|
if (decision.evidencePinIds.length)
|
|
548
549
|
lines.push(` Evidence pins: ${escapeMarkdownInline(decision.evidencePinIds.join(', '))}`);
|
|
549
550
|
}
|
|
@@ -555,6 +556,10 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
555
556
|
for (const action of response.actions) {
|
|
556
557
|
lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
|
|
557
558
|
lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
|
|
559
|
+
if (action.routeObservedAt)
|
|
560
|
+
lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
|
|
561
|
+
if (action.routeReviewAfter)
|
|
562
|
+
lines.push(` Route review due: ${escapeMarkdownInline(action.routeReviewAfter)}`);
|
|
558
563
|
if (action.originActionId)
|
|
559
564
|
lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
|
|
560
565
|
if (action.dueAt)
|
|
@@ -13,9 +13,11 @@ export const CASE_ACTION_TYPES = [
|
|
|
13
13
|
'registry_report',
|
|
14
14
|
'network_hosting_report',
|
|
15
15
|
'security_contact_report',
|
|
16
|
+
'platform_report',
|
|
16
17
|
'defensive_control',
|
|
17
18
|
'internal_review',
|
|
18
19
|
];
|
|
20
|
+
export const CASE_DECISION_CONFIDENCE_LEVELS = ['unknown', 'low', 'moderate', 'high'];
|
|
19
21
|
export const CASE_ACTION_STATES = [
|
|
20
22
|
'drafting',
|
|
21
23
|
'ready_for_review',
|
|
@@ -101,6 +103,7 @@ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
|
101
103
|
const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
|
|
102
104
|
const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
|
|
103
105
|
const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
|
|
106
|
+
const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
|
|
104
107
|
const ACTION_STATES = new Set(CASE_ACTION_STATES);
|
|
105
108
|
const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
|
|
106
109
|
const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
|
|
@@ -337,6 +340,9 @@ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
|
|
|
337
340
|
.slice(-MAX_CASE_EVIDENCE_PINS);
|
|
338
341
|
}
|
|
339
342
|
export function appendCaseEvidencePin(current, raw, now) {
|
|
343
|
+
if (current.length >= MAX_CASE_EVIDENCE_PINS) {
|
|
344
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_EVIDENCE_PINS} evidence pins. No existing evidence was removed.`);
|
|
345
|
+
}
|
|
340
346
|
const item = record(raw);
|
|
341
347
|
const created = normalizePin({ ...item, id: freshId('pin'), createdAt: now }, now);
|
|
342
348
|
if (!created)
|
|
@@ -363,10 +369,17 @@ function normalizeDecision(raw, fallback, validPinIds, options = {}) {
|
|
|
363
369
|
if (!summary || !rationale)
|
|
364
370
|
return null;
|
|
365
371
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
372
|
+
const currentConfidence = typeof item.confidence === 'string' && DECISION_CONFIDENCE_LEVELS.has(item.confidence)
|
|
373
|
+
? item.confidence
|
|
374
|
+
: 'unknown';
|
|
366
375
|
return {
|
|
367
376
|
id: safeId(item.id, 'decision', { summary, rationale, createdAt }),
|
|
368
377
|
summary,
|
|
369
378
|
rationale,
|
|
379
|
+
confidence: options.sourceVersion != null && options.sourceVersion < 15 ? 'unknown' : currentConfidence,
|
|
380
|
+
confidenceBasis: options.sourceVersion != null && options.sourceVersion < 15
|
|
381
|
+
? ''
|
|
382
|
+
: text(item.confidenceBasis, MAX_RESPONSE_RATIONALE_LENGTH),
|
|
370
383
|
evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
|
|
371
384
|
createdAt,
|
|
372
385
|
};
|
|
@@ -385,6 +398,9 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
|
|
|
385
398
|
.slice(-MAX_CASE_DECISIONS);
|
|
386
399
|
}
|
|
387
400
|
export function appendCaseDecision(current, raw, now, validPinIds) {
|
|
401
|
+
if (current.length >= MAX_CASE_DECISIONS) {
|
|
402
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
|
|
403
|
+
}
|
|
388
404
|
const item = record(raw);
|
|
389
405
|
const created = normalizeDecision({ ...item, id: freshId('decision'), createdAt: now }, now, validPinIds);
|
|
390
406
|
if (!created)
|
|
@@ -661,16 +677,28 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
661
677
|
const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
|
|
662
678
|
const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
|
|
663
679
|
const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
|
|
680
|
+
const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
|
|
681
|
+
const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
|
|
682
|
+
&& item.type === 'security_contact_report'
|
|
683
|
+
? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
|
|
684
|
+
: null;
|
|
664
685
|
return {
|
|
665
686
|
id: actionId,
|
|
666
|
-
type:
|
|
667
|
-
?
|
|
668
|
-
: '
|
|
687
|
+
type: legacyPlatformReview
|
|
688
|
+
? 'platform_report'
|
|
689
|
+
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
690
|
+
? item.type
|
|
691
|
+
: 'internal_review',
|
|
669
692
|
recipient,
|
|
670
|
-
contactSource
|
|
671
|
-
routeObservedAt:
|
|
693
|
+
contactSource,
|
|
694
|
+
routeObservedAt: legacyPlatformReview
|
|
695
|
+
? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
|
|
696
|
+
: options.sourceVersion != null && options.sourceVersion < 14
|
|
697
|
+
? null
|
|
698
|
+
: optionalIso(item.routeObservedAt, options),
|
|
699
|
+
routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
|
|
672
700
|
? null
|
|
673
|
-
: optionalIso(item.
|
|
701
|
+
: optionalIso(item.routeReviewAfter, options),
|
|
674
702
|
contactLimitations: limitations(item.contactLimitations),
|
|
675
703
|
dueAt: optionalIso(item.dueAt, options),
|
|
676
704
|
state: history.state,
|
|
@@ -1112,6 +1140,9 @@ export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}
|
|
|
1112
1140
|
.slice(-MAX_CASE_ASSERTIONS);
|
|
1113
1141
|
}
|
|
1114
1142
|
export function appendCaseAssertion(current, raw, now, validPinIds) {
|
|
1143
|
+
if (current.length >= MAX_CASE_ASSERTIONS) {
|
|
1144
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_ASSERTIONS} assertions. No existing assertion was removed.`);
|
|
1145
|
+
}
|
|
1115
1146
|
const item = record(raw);
|
|
1116
1147
|
const created = normalizeAssertion({
|
|
1117
1148
|
...item,
|
|
@@ -1170,6 +1201,9 @@ export function normalizeCaseManualTrail(raw, fallback, options = {}) {
|
|
|
1170
1201
|
.slice(-MAX_CASE_MANUAL_TRAIL_EVENTS);
|
|
1171
1202
|
}
|
|
1172
1203
|
export function appendCaseManualTrailEvent(current, raw, now) {
|
|
1204
|
+
if (current.length >= MAX_CASE_MANUAL_TRAIL_EVENTS) {
|
|
1205
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_MANUAL_TRAIL_EVENTS} investigation-trail entries. No existing entry was removed.`);
|
|
1206
|
+
}
|
|
1173
1207
|
const item = record(raw);
|
|
1174
1208
|
const created = normalizeManualTrailEvent({ ...item, id: freshId('trail'), createdAt: now }, now);
|
|
1175
1209
|
if (!created)
|
|
@@ -1234,6 +1268,9 @@ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {})
|
|
|
1234
1268
|
.slice(-MAX_CASE_SIGHTINGS);
|
|
1235
1269
|
}
|
|
1236
1270
|
export function appendCaseSighting(current, raw, now, validPinIds) {
|
|
1271
|
+
if (current.length >= MAX_CASE_SIGHTINGS) {
|
|
1272
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_SIGHTINGS} sightings. No existing sighting was removed.`);
|
|
1273
|
+
}
|
|
1237
1274
|
const item = record(raw);
|
|
1238
1275
|
const created = normalizeCaseSighting({
|
|
1239
1276
|
...item,
|