@slicedearth/whoisleuth-cli 2.1.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 +24 -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 +63 -13
- 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 +240 -3
- 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 +183 -53
- 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 +115 -46
- 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,11 +1,103 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
3
4
|
import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
|
|
4
5
|
import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
6
|
+
import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
|
|
5
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";
|
|
6
8
|
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
7
9
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
8
10
|
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
11
|
+
export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
|
|
12
|
+
export const MAX_CASE_OBJECTIVE_LENGTH = 320;
|
|
13
|
+
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
14
|
+
export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
|
|
15
|
+
const OBJECTIVE_PREFIX = 'Objective: ';
|
|
16
|
+
const RETENTION_SEPARATOR = ' | URL retained: ';
|
|
17
|
+
function boundedContextText(value, maximum) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
export function normalizeCaseObjective(value) {
|
|
23
|
+
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
24
|
+
}
|
|
25
|
+
export function parseIncidentUrlContext(value) {
|
|
26
|
+
if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
27
|
+
return null;
|
|
28
|
+
if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
|
|
29
|
+
return null;
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(value);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
|
|
38
|
+
return null;
|
|
39
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
40
|
+
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
41
|
+
if (!registrableDomain)
|
|
42
|
+
return null;
|
|
43
|
+
const exactUrl = parsed.toString();
|
|
44
|
+
if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
45
|
+
return null;
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
exactUrl,
|
|
48
|
+
hostname,
|
|
49
|
+
registrableDomain,
|
|
50
|
+
originUrl: parsed.origin,
|
|
51
|
+
hasPath: parsed.pathname !== '/',
|
|
52
|
+
hasQuery: Boolean(parsed.search),
|
|
53
|
+
hasFragment: Boolean(parsed.hash),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function caseInvestigationContext(record) {
|
|
57
|
+
if (!record)
|
|
58
|
+
return null;
|
|
59
|
+
for (const assertion of [...record.assertions].reverse()) {
|
|
60
|
+
if (assertion.kind !== 'next_step' || assertion.state !== 'open'
|
|
61
|
+
|| !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
|
|
62
|
+
continue;
|
|
63
|
+
const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
|
|
64
|
+
const parsed = parseIncidentUrlContext(incidentUrl);
|
|
65
|
+
if (!parsed || parsed.registrableDomain !== record.domain)
|
|
66
|
+
continue;
|
|
67
|
+
const rationale = assertion.rationale ?? '';
|
|
68
|
+
const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
|
|
69
|
+
const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
|
|
70
|
+
? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
|
|
71
|
+
: '';
|
|
72
|
+
const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
|
|
73
|
+
if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
|
|
74
|
+
continue;
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
objective,
|
|
77
|
+
incidentUrl: parsed.exactUrl,
|
|
78
|
+
urlRetention: retention,
|
|
79
|
+
assertionId: assertion.id,
|
|
80
|
+
updatedAt: assertion.updatedAt,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
export function caseInvestigationContextAssertion(input) {
|
|
86
|
+
const objective = normalizeCaseObjective(input.objective);
|
|
87
|
+
if (!objective)
|
|
88
|
+
throw new Error('Enter the investigation objective before retaining Incident context.');
|
|
89
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
90
|
+
if (!parsed)
|
|
91
|
+
throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
|
|
92
|
+
const retention = input.retainExactUrl ? 'exact' : 'origin_only';
|
|
93
|
+
const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
|
|
96
|
+
rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
|
|
97
|
+
retainedUrl,
|
|
98
|
+
retention,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
9
101
|
// ---------------------------------------------------------------------------
|
|
10
102
|
// Case normalization
|
|
11
103
|
// ---------------------------------------------------------------------------
|
|
@@ -66,7 +158,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
66
158
|
return {
|
|
67
159
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
68
160
|
domain,
|
|
69
|
-
status: normalizedStatus
|
|
161
|
+
status: caseStatusRequiresClosure(normalizedStatus)
|
|
70
162
|
&& closures.records.length === 0 && !closures.preV13HistoryUnavailable
|
|
71
163
|
? 'reviewing'
|
|
72
164
|
: normalizedStatus,
|
|
@@ -124,7 +216,7 @@ export function createCase(input, nowIso) {
|
|
|
124
216
|
const closures = input.closure !== undefined
|
|
125
217
|
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
126
218
|
: normalizeCaseClosureHistory(undefined, now);
|
|
127
|
-
if (normalizeStatus(input.status)
|
|
219
|
+
if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
|
|
128
220
|
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
129
221
|
}
|
|
130
222
|
return {
|
|
@@ -266,7 +358,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
266
358
|
if (patch.closure !== undefined) {
|
|
267
359
|
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
268
360
|
}
|
|
269
|
-
if (patch.status
|
|
361
|
+
if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
|
|
270
362
|
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
271
363
|
}
|
|
272
364
|
const record = {
|
|
@@ -300,3 +392,148 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
300
392
|
next[index] = record;
|
|
301
393
|
return { cases: next, record };
|
|
302
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Records one reviewed conclusion as a single pure Case mutation. The selected
|
|
397
|
+
* observations become bounded Case pins before the decision is created, so the
|
|
398
|
+
* decision cannot point at absent evidence. Counterevidence is retained as a
|
|
399
|
+
* separate resolved contradiction assertion rather than being hidden inside a
|
|
400
|
+
* favourable disposition.
|
|
401
|
+
*/
|
|
402
|
+
export function recordCaseConclusion(cases, id, input, nowIso) {
|
|
403
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
404
|
+
const disposition = normalizeDisposition(input.disposition);
|
|
405
|
+
const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
|
|
406
|
+
if (!isReviewedCaseDisposition(disposition)) {
|
|
407
|
+
throw new Error('Select a reviewed disposition before recording a conclusion.');
|
|
408
|
+
}
|
|
409
|
+
if (!reviewReasonCode) {
|
|
410
|
+
throw new Error('Select the reviewed reason before recording a conclusion.');
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(input.evidence) || !input.evidence.length) {
|
|
413
|
+
throw new Error('Select at least one observed fact for this conclusion.');
|
|
414
|
+
}
|
|
415
|
+
const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
|
|
416
|
+
if (invalidStance)
|
|
417
|
+
throw new Error('A conclusion evidence relationship is invalid.');
|
|
418
|
+
if (!input.evidence.some((item) => item.stance === 'supports')) {
|
|
419
|
+
throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
|
|
420
|
+
}
|
|
421
|
+
const current = cases.find((item) => item.id === id);
|
|
422
|
+
if (!current)
|
|
423
|
+
throw new Error('That case no longer exists.');
|
|
424
|
+
const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
425
|
+
const withPins = updateCase(cases, id, {
|
|
426
|
+
evidencePins: input.evidence.map((item) => item.pin),
|
|
427
|
+
}, now);
|
|
428
|
+
const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
|
|
429
|
+
if (addedPins.length !== input.evidence.length) {
|
|
430
|
+
throw new Error('The selected conclusion evidence could not be retained completely.');
|
|
431
|
+
}
|
|
432
|
+
const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
|
|
433
|
+
let concluded = updateCase(withPins.cases, id, {
|
|
434
|
+
disposition,
|
|
435
|
+
reviewReasonCode,
|
|
436
|
+
decision: {
|
|
437
|
+
summary: input.summary,
|
|
438
|
+
rationale: input.rationale,
|
|
439
|
+
evidencePinIds: supportingPinIds,
|
|
440
|
+
},
|
|
441
|
+
}, now);
|
|
442
|
+
const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
|
|
443
|
+
? [{ evidencePinId: pin.id, stance: 'contradicts' }]
|
|
444
|
+
: []));
|
|
445
|
+
if (contradictionRelations.length) {
|
|
446
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
447
|
+
concluded = updateCase(concluded.cases, id, {
|
|
448
|
+
assertion: {
|
|
449
|
+
kind: 'contradiction',
|
|
450
|
+
statement: `Counterevidence considered for: ${retainedSummary}`,
|
|
451
|
+
rationale: input.rationale,
|
|
452
|
+
evidenceRelations: contradictionRelations,
|
|
453
|
+
state: 'resolved',
|
|
454
|
+
},
|
|
455
|
+
}, now);
|
|
456
|
+
}
|
|
457
|
+
const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
|
|
458
|
+
? [{ evidencePinId: pin.id, stance: 'unresolved' }]
|
|
459
|
+
: []));
|
|
460
|
+
if (unresolvedRelations.length) {
|
|
461
|
+
const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
|
|
462
|
+
concluded = updateCase(concluded.cases, id, {
|
|
463
|
+
assertion: {
|
|
464
|
+
kind: 'unknown',
|
|
465
|
+
statement: `Unresolved evidence considered for: ${retainedSummary}`,
|
|
466
|
+
rationale: input.rationale,
|
|
467
|
+
evidenceRelations: unresolvedRelations,
|
|
468
|
+
state: 'open',
|
|
469
|
+
},
|
|
470
|
+
}, now);
|
|
471
|
+
}
|
|
472
|
+
return concluded;
|
|
473
|
+
}
|
|
474
|
+
export function recordCaseInvestigationContext(cases, id, input, nowIso) {
|
|
475
|
+
const current = cases.find((item) => item.id === id);
|
|
476
|
+
if (!current)
|
|
477
|
+
throw new Error('That case no longer exists.');
|
|
478
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
479
|
+
if (!parsed || parsed.registrableDomain !== current.domain) {
|
|
480
|
+
throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
|
|
481
|
+
}
|
|
482
|
+
const context = caseInvestigationContextAssertion(input);
|
|
483
|
+
const existing = caseInvestigationContext(current);
|
|
484
|
+
return updateCase(cases, id, existing
|
|
485
|
+
? {
|
|
486
|
+
assertionUpdate: {
|
|
487
|
+
id: existing.assertionId,
|
|
488
|
+
statement: context.statement,
|
|
489
|
+
rationale: context.rationale,
|
|
490
|
+
state: 'open',
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
: {
|
|
494
|
+
assertion: {
|
|
495
|
+
kind: 'next_step',
|
|
496
|
+
statement: context.statement,
|
|
497
|
+
rationale: context.rationale,
|
|
498
|
+
evidenceRelations: [],
|
|
499
|
+
state: 'open',
|
|
500
|
+
},
|
|
501
|
+
}, nowIso);
|
|
502
|
+
}
|
|
503
|
+
export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
|
|
504
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
505
|
+
const current = cases.find((item) => item.id === id);
|
|
506
|
+
if (!current)
|
|
507
|
+
throw new Error('That case no longer exists.');
|
|
508
|
+
const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
|
|
509
|
+
const withPin = updateCase(cases, id, {
|
|
510
|
+
evidencePin: {
|
|
511
|
+
field: 'case.recheck_comparison',
|
|
512
|
+
category: 'recheck',
|
|
513
|
+
label: 'Recheck comparison',
|
|
514
|
+
value: input.comparisonSummary,
|
|
515
|
+
source: input.source,
|
|
516
|
+
sourceState: 'reviewed',
|
|
517
|
+
observedAt: input.observedAt,
|
|
518
|
+
collectionDepth: input.collectionDepth,
|
|
519
|
+
completeness: input.completeness,
|
|
520
|
+
truncated: false,
|
|
521
|
+
limitations: input.limitations,
|
|
522
|
+
},
|
|
523
|
+
}, now);
|
|
524
|
+
const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
|
|
525
|
+
if (!comparisonPin)
|
|
526
|
+
throw new Error('The recheck comparison could not be retained.');
|
|
527
|
+
return updateCase(withPin.cases, id, {
|
|
528
|
+
observedEffectReview: {
|
|
529
|
+
state: input.state,
|
|
530
|
+
observedAt: input.observedAt,
|
|
531
|
+
sourceClass: 'analyst',
|
|
532
|
+
source: input.source,
|
|
533
|
+
completeness: input.completeness,
|
|
534
|
+
evidencePinId: comparisonPin.id,
|
|
535
|
+
followUpAt: input.followUpAt,
|
|
536
|
+
limitations: input.limitations,
|
|
537
|
+
},
|
|
538
|
+
}, now);
|
|
539
|
+
}
|
|
@@ -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,
|