@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4
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 +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { normalizeDomain } from "./case-model.js";
|
|
5
5
|
import { normalizeCaaCritical } from "./dns-record-normalization.js";
|
|
6
6
|
export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
|
|
7
|
-
export const BULK_SESSION_SCHEMA_VERSION =
|
|
8
|
-
export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, BULK_SESSION_SCHEMA_VERSION]);
|
|
7
|
+
export const BULK_SESSION_SCHEMA_VERSION = 4;
|
|
8
|
+
export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, BULK_SESSION_SCHEMA_VERSION]);
|
|
9
9
|
export const MAX_BULK_SESSIONS = 10;
|
|
10
10
|
export const MAX_BULK_SESSION_ROWS = 2_000;
|
|
11
11
|
export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
|
|
@@ -13,6 +13,11 @@ export const MAX_BULK_SESSION_NAME_LENGTH = 100;
|
|
|
13
13
|
export const MAX_BULK_SESSION_TEXT_LENGTH = 500;
|
|
14
14
|
export const MAX_BULK_SESSION_ARRAY_VALUES = 100;
|
|
15
15
|
export const MAX_BULK_SESSION_SOURCES = 12;
|
|
16
|
+
export const MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH = 300;
|
|
17
|
+
export const BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION = 'Brand Profile context was unavailable when this row was evaluated. Trust, profile matches, and the profile-dependent Risk assessment remain inconclusive.';
|
|
18
|
+
export const BULK_PROFILE_CONTEXT_LEGACY_LIMITATION = 'This legacy Bulk row did not retain profile-context provenance. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned.';
|
|
19
|
+
export const BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION = 'This saved Bulk row was evaluated with a different or unreadable Brand Profile context. Profile-derived conclusions and the Risk assessment are withheld until the domain is rescanned.';
|
|
20
|
+
export const BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION = 'This row came from a portable import whose claimed Brand Profile identity and revision cannot authenticate profile-derived conclusions. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned locally.';
|
|
16
21
|
const CONTROL_RE = /[\x00-\x1f\x7f]/u;
|
|
17
22
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
18
23
|
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
@@ -49,6 +54,74 @@ function timestamp(value, fallback = null) {
|
|
|
49
54
|
const text = boundedText(value, 64);
|
|
50
55
|
return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
|
|
51
56
|
}
|
|
57
|
+
function profileContextLimitation(value, fallback) {
|
|
58
|
+
return boundedText(value, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH) || fallback;
|
|
59
|
+
}
|
|
60
|
+
export function unavailableBulkProfileContext(limitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
|
|
61
|
+
return {
|
|
62
|
+
sourceState: 'unavailable',
|
|
63
|
+
activeProfileId: null,
|
|
64
|
+
profileUpdatedAt: null,
|
|
65
|
+
limitation: profileContextLimitation(limitation, BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function bulkProfileContextProvenance(sourceState, profileRaw) {
|
|
69
|
+
if (sourceState !== 'ready')
|
|
70
|
+
return unavailableBulkProfileContext();
|
|
71
|
+
if (profileRaw === null || profileRaw === undefined) {
|
|
72
|
+
return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
|
|
73
|
+
}
|
|
74
|
+
const profile = record(profileRaw);
|
|
75
|
+
const activeProfileId = typeof profile?.id === 'string' && SAFE_ID_RE.test(profile.id) ? profile.id : null;
|
|
76
|
+
const profileUpdatedAt = timestamp(profile?.updatedAt);
|
|
77
|
+
return activeProfileId && profileUpdatedAt
|
|
78
|
+
? { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' }
|
|
79
|
+
: unavailableBulkProfileContext('The active Brand Profile identity or revision was invalid when this row was evaluated. Profile-derived conclusions and the Risk assessment remain inconclusive.');
|
|
80
|
+
}
|
|
81
|
+
export function normalizeBulkProfileContext(value, fallbackLimitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
|
|
82
|
+
const item = record(value);
|
|
83
|
+
const sourceState = item?.sourceState;
|
|
84
|
+
if (sourceState === 'ready') {
|
|
85
|
+
if (item?.activeProfileId === null && item.profileUpdatedAt === null) {
|
|
86
|
+
return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
|
|
87
|
+
}
|
|
88
|
+
const activeProfileId = typeof item?.activeProfileId === 'string' && SAFE_ID_RE.test(item.activeProfileId)
|
|
89
|
+
? item.activeProfileId
|
|
90
|
+
: null;
|
|
91
|
+
const profileUpdatedAt = timestamp(item?.profileUpdatedAt);
|
|
92
|
+
if (activeProfileId && profileUpdatedAt) {
|
|
93
|
+
return { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (sourceState === 'mixed') {
|
|
97
|
+
return {
|
|
98
|
+
sourceState: 'mixed',
|
|
99
|
+
activeProfileId: null,
|
|
100
|
+
profileUpdatedAt: null,
|
|
101
|
+
limitation: profileContextLimitation(item?.limitation, 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.'),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return unavailableBulkProfileContext(profileContextLimitation(item?.limitation, fallbackLimitation));
|
|
105
|
+
}
|
|
106
|
+
function sameProfileContext(left, right) {
|
|
107
|
+
return left.sourceState === right.sourceState
|
|
108
|
+
&& left.activeProfileId === right.activeProfileId
|
|
109
|
+
&& left.profileUpdatedAt === right.profileUpdatedAt
|
|
110
|
+
&& left.limitation === right.limitation;
|
|
111
|
+
}
|
|
112
|
+
export function summarizeBulkProfileContexts(rows) {
|
|
113
|
+
const first = rows[0]?.profileContext;
|
|
114
|
+
if (!first)
|
|
115
|
+
return unavailableBulkProfileContext('This saved Bulk session contains no settled row with profile-context provenance.');
|
|
116
|
+
if (rows.every((row) => sameProfileContext(first, row.profileContext)))
|
|
117
|
+
return { ...first };
|
|
118
|
+
return {
|
|
119
|
+
sourceState: 'mixed',
|
|
120
|
+
activeProfileId: null,
|
|
121
|
+
profileUpdatedAt: null,
|
|
122
|
+
limitation: 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
52
125
|
function nullableBoolean(value) {
|
|
53
126
|
return typeof value === 'boolean' ? value : null;
|
|
54
127
|
}
|
|
@@ -208,14 +281,37 @@ export function normalizeBulkComparisonEvidence(value) {
|
|
|
208
281
|
},
|
|
209
282
|
};
|
|
210
283
|
}
|
|
211
|
-
export function normalizeBulkSessionResult(value) {
|
|
284
|
+
export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_SCHEMA_VERSION) {
|
|
212
285
|
const item = record(value);
|
|
213
286
|
const domain = normalizeDomain(item?.domain);
|
|
214
287
|
const status = boundedText(item?.status, 20);
|
|
215
288
|
const scanDepth = boundedText(item?.scanDepth, 10);
|
|
216
289
|
if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
|
|
217
290
|
return null;
|
|
291
|
+
const rawProfileContext = record(item.profileContext);
|
|
292
|
+
if (sourceVersion >= 4
|
|
293
|
+
&& (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState))))
|
|
294
|
+
return null;
|
|
295
|
+
const profileContext = sourceVersion >= 4
|
|
296
|
+
? normalizeBulkProfileContext(rawProfileContext)
|
|
297
|
+
: unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_LEGACY_LIMITATION);
|
|
298
|
+
if (sourceVersion >= 4 && rawProfileContext?.sourceState === 'ready' && profileContext.sourceState !== 'ready')
|
|
299
|
+
return null;
|
|
300
|
+
const profileContextReady = profileContext.sourceState === 'ready';
|
|
218
301
|
const trusted = boundedText(item.trusted, 20);
|
|
302
|
+
const relationship = normalizeRelationship(item.relationship);
|
|
303
|
+
const readyWithoutActiveProfile = profileContextReady && profileContext.activeProfileId === null;
|
|
304
|
+
const impossibleNoProfileClaim = readyWithoutActiveProfile && (TRUST_STATES.has(trusted)
|
|
305
|
+
|| item.faviconMatch === true
|
|
306
|
+
|| item.faviconNearMatch === true
|
|
307
|
+
|| item.reusesOfficialAssets === true
|
|
308
|
+
|| item.idnReferenceMatch === true
|
|
309
|
+
|| item.pageBaselineMatch === true
|
|
310
|
+
|| item.hasActiveBrandProfile === true
|
|
311
|
+
|| relationship.officialAssetHosts.length > 0);
|
|
312
|
+
const profileClaimsUsable = profileContextReady && !impossibleNoProfileClaim;
|
|
313
|
+
if (!profileClaimsUsable || readyWithoutActiveProfile)
|
|
314
|
+
relationship.officialAssetHosts = [];
|
|
219
315
|
return {
|
|
220
316
|
domain,
|
|
221
317
|
status: status,
|
|
@@ -223,10 +319,10 @@ export function normalizeBulkSessionResult(value) {
|
|
|
223
319
|
confidence: boundedText(item.confidence, 40) || 'unknown',
|
|
224
320
|
registrar: boundedText(item.registrar, 300) || '—',
|
|
225
321
|
activity: boundedText(item.activity, 300) || '—',
|
|
226
|
-
risk: nullableScore(item.risk),
|
|
322
|
+
risk: profileClaimsUsable ? nullableScore(item.risk) : null,
|
|
227
323
|
opportunity: nullableScore(item.opportunity),
|
|
228
324
|
mutationTypes: boundedStrings(item.mutationTypes, 40),
|
|
229
|
-
trusted: TRUST_STATES.has(trusted) ? trusted : null,
|
|
325
|
+
trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
|
|
230
326
|
error: boundedText(item.error),
|
|
231
327
|
scanDepth: scanDepth,
|
|
232
328
|
createdDate: boundedText(item.createdDate, 64) || null,
|
|
@@ -241,26 +337,27 @@ export function normalizeBulkSessionResult(value) {
|
|
|
241
337
|
pageTitle: boundedText(item.pageTitle, 300) || null,
|
|
242
338
|
faviconHash: nullableHash(item.faviconHash, HASH_64_RE),
|
|
243
339
|
faviconPHash: nullableHash(item.faviconPHash, PHASH_RE),
|
|
244
|
-
faviconMatch: item.faviconMatch
|
|
245
|
-
faviconNearMatch: item.faviconNearMatch
|
|
246
|
-
reusesOfficialAssets: item.reusesOfficialAssets
|
|
340
|
+
faviconMatch: profileClaimsUsable ? nullableBoolean(item.faviconMatch) : null,
|
|
341
|
+
faviconNearMatch: profileClaimsUsable ? nullableBoolean(item.faviconNearMatch) : null,
|
|
342
|
+
reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
|
|
247
343
|
hasPasswordField: item.hasPasswordField === true,
|
|
248
344
|
hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
|
|
249
345
|
phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
|
|
250
|
-
idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
|
|
251
|
-
pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
|
|
252
|
-
hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
|
|
253
|
-
riskModelVersion: nullableVersion(item.riskModelVersion),
|
|
346
|
+
idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
|
|
347
|
+
pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
|
|
348
|
+
hasActiveBrandProfile: profileClaimsUsable ? nullableBoolean(item.hasActiveBrandProfile) : null,
|
|
349
|
+
riskModelVersion: profileClaimsUsable ? nullableVersion(item.riskModelVersion) : null,
|
|
254
350
|
opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
|
|
255
|
-
riskFactors: normalizeRiskFactors(item.riskFactors),
|
|
351
|
+
riskFactors: profileClaimsUsable ? normalizeRiskFactors(item.riskFactors) : [],
|
|
256
352
|
dns: normalizeDns(item.dns),
|
|
257
353
|
dnssec: boundedText(item.dnssec, 40) || null,
|
|
258
354
|
comparisonEvidence: normalizeBulkComparisonEvidence(item.comparisonEvidence),
|
|
259
|
-
relationship
|
|
355
|
+
relationship,
|
|
260
356
|
sourceCoverage: normalizeSourceCoverage(item.sourceCoverage),
|
|
357
|
+
profileContext,
|
|
261
358
|
};
|
|
262
359
|
}
|
|
263
|
-
export function normalizeBulkSession(value) {
|
|
360
|
+
export function normalizeBulkSession(value, sourceVersion = BULK_SESSION_SCHEMA_VERSION) {
|
|
264
361
|
const item = record(value);
|
|
265
362
|
const id = boundedText(item?.id, 128);
|
|
266
363
|
const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
|
|
@@ -282,18 +379,33 @@ export function normalizeBulkSession(value) {
|
|
|
282
379
|
const domains = domainList(item.domains);
|
|
283
380
|
if (!domains.length)
|
|
284
381
|
return null;
|
|
382
|
+
if (sourceVersion >= 4 && !Array.isArray(item.results))
|
|
383
|
+
return null;
|
|
285
384
|
const allowed = new Set(domains);
|
|
286
385
|
const results = [];
|
|
287
386
|
const seen = new Set();
|
|
288
387
|
for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
|
|
289
|
-
const result = normalizeBulkSessionResult(candidate);
|
|
290
|
-
if (!result || !allowed.has(result.domain) || seen.has(result.domain))
|
|
388
|
+
const result = normalizeBulkSessionResult(candidate, sourceVersion);
|
|
389
|
+
if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
|
|
390
|
+
if (sourceVersion >= 4)
|
|
391
|
+
return null;
|
|
291
392
|
continue;
|
|
393
|
+
}
|
|
292
394
|
seen.add(result.domain);
|
|
293
395
|
results.push(result);
|
|
294
|
-
if (results.length >= MAX_BULK_SESSION_ROWS)
|
|
396
|
+
if (sourceVersion < 4 && results.length >= MAX_BULK_SESSION_ROWS)
|
|
295
397
|
break;
|
|
296
398
|
}
|
|
399
|
+
if (sourceVersion >= 4 && ((state === 'complete') !== (results.length === domains.length)))
|
|
400
|
+
return null;
|
|
401
|
+
const profileContext = summarizeBulkProfileContexts(results);
|
|
402
|
+
if (sourceVersion >= 4) {
|
|
403
|
+
if (!record(item.profileContext))
|
|
404
|
+
return null;
|
|
405
|
+
const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
|
|
406
|
+
if (!sameProfileContext(declaredProfileContext, profileContext))
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
297
409
|
return {
|
|
298
410
|
id,
|
|
299
411
|
name,
|
|
@@ -305,6 +417,7 @@ export function normalizeBulkSession(value) {
|
|
|
305
417
|
startedAt,
|
|
306
418
|
updatedAt,
|
|
307
419
|
completedAt,
|
|
420
|
+
profileContext,
|
|
308
421
|
};
|
|
309
422
|
}
|
|
310
423
|
export function bulkSessionStoreVersion(raw) {
|
|
@@ -315,6 +428,10 @@ export function bulkSessionStoreVersion(raw) {
|
|
|
315
428
|
}
|
|
316
429
|
export function normalizeBulkSessionStore(raw) {
|
|
317
430
|
const value = record(raw);
|
|
431
|
+
const sourceVersion = value?.schema === BULK_SESSION_SCHEMA
|
|
432
|
+
&& SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))
|
|
433
|
+
? Number(value.version)
|
|
434
|
+
: null;
|
|
318
435
|
const candidates = Array.isArray(raw)
|
|
319
436
|
? raw
|
|
320
437
|
: value?.schema === BULK_SESSION_SCHEMA
|
|
@@ -324,7 +441,17 @@ export function normalizeBulkSessionStore(raw) {
|
|
|
324
441
|
: [];
|
|
325
442
|
const byId = new Map();
|
|
326
443
|
for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
327
|
-
const
|
|
444
|
+
const candidateRecord = record(candidate);
|
|
445
|
+
const resultRecords = Array.isArray(candidateRecord?.results)
|
|
446
|
+
? candidateRecord.results.map(record).filter((item) => Boolean(item))
|
|
447
|
+
: [];
|
|
448
|
+
const whollyUnversionedLegacy = Array.isArray(raw)
|
|
449
|
+
&& !Object.hasOwn(candidateRecord ?? {}, 'profileContext')
|
|
450
|
+
&& resultRecords.every((item) => !Object.hasOwn(item, 'profileContext'));
|
|
451
|
+
const candidateVersion = Array.isArray(raw)
|
|
452
|
+
? whollyUnversionedLegacy ? 3 : BULK_SESSION_SCHEMA_VERSION
|
|
453
|
+
: sourceVersion ?? BULK_SESSION_SCHEMA_VERSION;
|
|
454
|
+
const session = normalizeBulkSession(candidate, candidateVersion);
|
|
328
455
|
if (!session)
|
|
329
456
|
continue;
|
|
330
457
|
const existing = byId.get(session.id);
|
|
@@ -389,12 +516,22 @@ export function deleteBulkSession(raw, idRaw) {
|
|
|
389
516
|
function sourceStateMap(value) {
|
|
390
517
|
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
391
518
|
}
|
|
519
|
+
function comparableRisk(previous, current) {
|
|
520
|
+
return previous.profileContext.sourceState === 'ready'
|
|
521
|
+
&& current.profileContext.sourceState === 'ready'
|
|
522
|
+
&& sameProfileContext(previous.profileContext, current.profileContext)
|
|
523
|
+
&& previous.risk !== null
|
|
524
|
+
&& current.risk !== null
|
|
525
|
+
&& previous.riskModelVersion !== null
|
|
526
|
+
&& previous.riskModelVersion === current.riskModelVersion;
|
|
527
|
+
}
|
|
392
528
|
function resultChanges(previous, current) {
|
|
393
529
|
const changes = [];
|
|
394
530
|
if (previous.availability !== current.availability)
|
|
395
531
|
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
396
|
-
if (previous.risk !== current.risk)
|
|
532
|
+
if (comparableRisk(previous, current) && previous.risk !== current.risk) {
|
|
397
533
|
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
534
|
+
}
|
|
398
535
|
if (previous.registrar !== current.registrar)
|
|
399
536
|
changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
|
|
400
537
|
if (previous.activity !== current.activity)
|
|
@@ -429,6 +566,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
429
566
|
const before = new Map(baseline.results.map((result) => [result.domain, result]));
|
|
430
567
|
const after = new Map(current.results.map((result) => [result.domain, result]));
|
|
431
568
|
const rows = [];
|
|
569
|
+
let riskIncomparable = false;
|
|
432
570
|
let added = 0;
|
|
433
571
|
let removed = 0;
|
|
434
572
|
let unchanged = 0;
|
|
@@ -444,6 +582,8 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
444
582
|
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
445
583
|
}
|
|
446
584
|
else {
|
|
585
|
+
if (!comparableRisk(previous, next))
|
|
586
|
+
riskIncomparable = true;
|
|
447
587
|
const changes = resultChanges(previous, next);
|
|
448
588
|
if (changes.length)
|
|
449
589
|
rows.push({ domain, changes });
|
|
@@ -463,6 +603,9 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
463
603
|
'This compares compact saved observations, not historical provider data or raw source payloads.',
|
|
464
604
|
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
465
605
|
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
606
|
+
...(riskIncomparable
|
|
607
|
+
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance and the same versioned Risk model.']
|
|
608
|
+
: []),
|
|
466
609
|
],
|
|
467
610
|
};
|
|
468
611
|
}
|
|
@@ -494,21 +637,44 @@ export function mergeBulkSessions(localRaw, importedRaw) {
|
|
|
494
637
|
let added = 0;
|
|
495
638
|
let updated = 0;
|
|
496
639
|
let skipped = 0;
|
|
640
|
+
const importedVersion = Number(imported.version);
|
|
497
641
|
for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
|
|
498
|
-
const
|
|
642
|
+
const normalized = normalizeBulkSession(candidate, importedVersion);
|
|
643
|
+
if (!normalized) {
|
|
644
|
+
skipped += 1;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const importedProfileContext = unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION);
|
|
648
|
+
const quarantinedResults = normalized.results.map((result) => ({
|
|
649
|
+
...result,
|
|
650
|
+
risk: null,
|
|
651
|
+
trusted: null,
|
|
652
|
+
faviconMatch: null,
|
|
653
|
+
faviconNearMatch: null,
|
|
654
|
+
reusesOfficialAssets: null,
|
|
655
|
+
idnReferenceMatch: null,
|
|
656
|
+
pageBaselineMatch: null,
|
|
657
|
+
hasActiveBrandProfile: null,
|
|
658
|
+
riskModelVersion: null,
|
|
659
|
+
riskFactors: [],
|
|
660
|
+
relationship: { ...result.relationship, officialAssetHosts: [] },
|
|
661
|
+
profileContext: importedProfileContext,
|
|
662
|
+
}));
|
|
663
|
+
const session = normalizeBulkSession({
|
|
664
|
+
...normalized,
|
|
665
|
+
results: quarantinedResults,
|
|
666
|
+
profileContext: summarizeBulkProfileContexts(quarantinedResults),
|
|
667
|
+
});
|
|
499
668
|
if (!session) {
|
|
500
669
|
skipped += 1;
|
|
501
670
|
continue;
|
|
502
671
|
}
|
|
503
672
|
const existing = sessions.find((item) => item.id === session.id);
|
|
504
|
-
if (
|
|
505
|
-
added += 1;
|
|
506
|
-
else if (existing.updatedAt !== session.updatedAt)
|
|
507
|
-
updated += 1;
|
|
508
|
-
else {
|
|
673
|
+
if (existing) {
|
|
509
674
|
skipped += 1;
|
|
510
675
|
continue;
|
|
511
676
|
}
|
|
677
|
+
added += 1;
|
|
512
678
|
sessions = upsertBulkSession(sessions, session).sessions;
|
|
513
679
|
}
|
|
514
680
|
const bounded = enforceBulkSessionStoreBudget({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, } from "./case-record-contracts.js";
|
|
2
|
+
import { normalizeOpaqueReferenceId } from "./opaque-reference-id.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads an untrusted stored or imported reference list without repairing any
|
|
5
|
+
* identifier. At most 32 candidates are inspected and at most eight unique,
|
|
6
|
+
* exact identifiers are retained.
|
|
7
|
+
*/
|
|
8
|
+
export function inspectCaseBrandProfileIds(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return { ids: [], omitted: 0 };
|
|
11
|
+
const ids = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
let omitted = Math.max(0, value.length - MAX_CASE_BRAND_PROFILE_ID_CANDIDATES);
|
|
14
|
+
for (const candidate of value.slice(0, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES)) {
|
|
15
|
+
const id = normalizeOpaqueReferenceId(candidate);
|
|
16
|
+
if (!id || seen.has(id))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(id);
|
|
19
|
+
if (ids.length < MAX_CASE_BRAND_PROFILE_IDS)
|
|
20
|
+
ids.push(id);
|
|
21
|
+
else
|
|
22
|
+
omitted += 1;
|
|
23
|
+
}
|
|
24
|
+
return { ids, omitted };
|
|
25
|
+
}
|
|
26
|
+
/** Current-store recovery is bounded and lenient, but never repairs ids. */
|
|
27
|
+
export function normalizeCaseBrandProfileIds(value) {
|
|
28
|
+
return inspectCaseBrandProfileIds(value).ids;
|
|
29
|
+
}
|
|
30
|
+
/** Explicit analyst edits replace the complete set and fail closed. */
|
|
31
|
+
export function assertCaseBrandProfileIds(value) {
|
|
32
|
+
if (!Array.isArray(value)) {
|
|
33
|
+
throw new Error('Brand Profile associations must be an array of opaque identifiers.');
|
|
34
|
+
}
|
|
35
|
+
if (value.length > MAX_CASE_BRAND_PROFILE_IDS) {
|
|
36
|
+
throw new Error(`Each case is limited to ${MAX_CASE_BRAND_PROFILE_IDS} Brand Profile associations.`);
|
|
37
|
+
}
|
|
38
|
+
const ids = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const candidate of value) {
|
|
41
|
+
const id = normalizeOpaqueReferenceId(candidate);
|
|
42
|
+
if (!id)
|
|
43
|
+
throw new Error('A Brand Profile association identifier is invalid.');
|
|
44
|
+
if (seen.has(id))
|
|
45
|
+
throw new Error('Brand Profile association identifiers must be unique.');
|
|
46
|
+
seen.add(id);
|
|
47
|
+
ids.push(id);
|
|
48
|
+
}
|
|
49
|
+
return ids;
|
|
50
|
+
}
|
|
51
|
+
/** Add one exact association without accepting a stale whole-list snapshot. */
|
|
52
|
+
export function addCaseBrandProfileId(value, profileId) {
|
|
53
|
+
const ids = assertCaseBrandProfileIds(value);
|
|
54
|
+
const [id] = assertCaseBrandProfileIds([profileId]);
|
|
55
|
+
if (!id || ids.includes(id))
|
|
56
|
+
return [...ids];
|
|
57
|
+
if (ids.length >= MAX_CASE_BRAND_PROFILE_IDS) {
|
|
58
|
+
throw new Error(`Each case is limited to ${MAX_CASE_BRAND_PROFILE_IDS} Brand Profile associations.`);
|
|
59
|
+
}
|
|
60
|
+
return [...ids, id];
|
|
61
|
+
}
|
|
62
|
+
/** Remove one exact association while preserving every unrelated reference. */
|
|
63
|
+
export function removeCaseBrandProfileId(value, profileId) {
|
|
64
|
+
const ids = assertCaseBrandProfileIds(value);
|
|
65
|
+
const [id] = assertCaseBrandProfileIds([profileId]);
|
|
66
|
+
return id ? ids.filter((candidate) => candidate !== id) : [...ids];
|
|
67
|
+
}
|
|
68
|
+
/** Import unions are existing-first so omission can never clear a local ref. */
|
|
69
|
+
export function unionCaseBrandProfileIds(existingValue, importedValue) {
|
|
70
|
+
const existing = normalizeCaseBrandProfileIds(existingValue);
|
|
71
|
+
const imported = normalizeCaseBrandProfileIds(importedValue);
|
|
72
|
+
const ids = [...existing];
|
|
73
|
+
const seen = new Set(ids);
|
|
74
|
+
let omitted = 0;
|
|
75
|
+
for (const id of imported) {
|
|
76
|
+
if (seen.has(id))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(id);
|
|
79
|
+
if (ids.length < MAX_CASE_BRAND_PROFILE_IDS)
|
|
80
|
+
ids.push(id);
|
|
81
|
+
else
|
|
82
|
+
omitted += 1;
|
|
83
|
+
}
|
|
84
|
+
return { ids, omitted };
|
|
85
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { normalizeHttpSummary } from "./http-summary.js";
|
|
4
4
|
import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
|
|
5
|
-
import {
|
|
5
|
+
import { 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, } from "./case-record-contracts.js";
|
|
6
6
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
|
|
7
7
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
8
8
|
if (value == null)
|
|
@@ -114,6 +114,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
114
114
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
115
115
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
116
116
|
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
117
|
+
'profileContextState', 'profileContextLimitation',
|
|
117
118
|
'mutationTypes',
|
|
118
119
|
];
|
|
119
120
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
@@ -199,6 +200,7 @@ function buildSnapshot(raw, options) {
|
|
|
199
200
|
const record = objectRecord(raw);
|
|
200
201
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
201
202
|
const httpSummary = normalizeHttpSummary(record);
|
|
203
|
+
const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
|
|
202
204
|
const fields = {
|
|
203
205
|
scanDepth,
|
|
204
206
|
availability: evidenceString(record.availability),
|
|
@@ -239,6 +241,12 @@ function buildSnapshot(raw, options) {
|
|
|
239
241
|
idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
|
|
240
242
|
pageBaselineMatch: boolOrNull(record.pageBaselineMatch),
|
|
241
243
|
hasActiveBrandProfile: boolOrNull(record.hasActiveBrandProfile),
|
|
244
|
+
profileContextState: acceptsProfileContext && ['loading', 'ready', 'unavailable'].includes(String(record.profileContextState))
|
|
245
|
+
? record.profileContextState
|
|
246
|
+
: null,
|
|
247
|
+
profileContextLimitation: acceptsProfileContext
|
|
248
|
+
? evidenceString(record.profileContextLimitation, MAX_EVIDENCE_DETAIL_LENGTH)
|
|
249
|
+
: null,
|
|
242
250
|
mutationTypes: normalizeMutationList(record.mutationTypes),
|
|
243
251
|
};
|
|
244
252
|
// A version without an actual risk assessment is orphaned metadata. Drop it
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export const MAX_CASE_INVESTIGATION_BRANCHES = 8;
|
|
2
|
+
export const MAX_CASE_BRANCH_NAME_LENGTH = 80;
|
|
3
|
+
export const MAX_CASE_BRANCH_REFERENCES = 12;
|
|
4
|
+
export const CASE_INVESTIGATION_BRANCH_STATES = ['active', 'resolved'];
|
|
5
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
6
|
+
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
7
|
+
function record(value) {
|
|
8
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
9
|
+
}
|
|
10
|
+
function text(value, maximum) {
|
|
11
|
+
return typeof value === 'string'
|
|
12
|
+
? value.replace(CONTROL_REPLACE_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
13
|
+
: '';
|
|
14
|
+
}
|
|
15
|
+
function iso(value, fallback) {
|
|
16
|
+
if (typeof value === 'string' && value.length <= 64 && !/[\u0000-\u001f\u007f]/u.test(value)) {
|
|
17
|
+
const parsed = Date.parse(value);
|
|
18
|
+
if (Number.isFinite(parsed))
|
|
19
|
+
return new Date(parsed).toISOString();
|
|
20
|
+
}
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
function hash(value) {
|
|
24
|
+
let output = 2166136261 >>> 0;
|
|
25
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
26
|
+
output ^= value.charCodeAt(index);
|
|
27
|
+
output = Math.imul(output, 16777619);
|
|
28
|
+
}
|
|
29
|
+
return (output >>> 0).toString(36);
|
|
30
|
+
}
|
|
31
|
+
function safeId(value, raw) {
|
|
32
|
+
return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : `branch-${hash(JSON.stringify(raw))}`;
|
|
33
|
+
}
|
|
34
|
+
function freshId() {
|
|
35
|
+
return typeof globalThis.crypto?.randomUUID === 'function'
|
|
36
|
+
? globalThis.crypto.randomUUID()
|
|
37
|
+
: `branch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
38
|
+
}
|
|
39
|
+
function ids(value, valid) {
|
|
40
|
+
if (!Array.isArray(value))
|
|
41
|
+
return [];
|
|
42
|
+
const output = new Set();
|
|
43
|
+
for (const item of value.slice(0, MAX_CASE_BRANCH_REFERENCES * 2)) {
|
|
44
|
+
if (typeof item === 'string' && SAFE_ID_RE.test(item) && valid.has(item))
|
|
45
|
+
output.add(item);
|
|
46
|
+
if (output.size >= MAX_CASE_BRANCH_REFERENCES)
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return [...output];
|
|
50
|
+
}
|
|
51
|
+
function normalizeBranch(value, fallback, references) {
|
|
52
|
+
const item = record(value);
|
|
53
|
+
const name = text(item.name, MAX_CASE_BRANCH_NAME_LENGTH);
|
|
54
|
+
if (!name)
|
|
55
|
+
return null;
|
|
56
|
+
const createdAt = iso(item.createdAt, fallback);
|
|
57
|
+
const branch = Object.freeze({
|
|
58
|
+
id: safeId(item.id, { name, createdAt }),
|
|
59
|
+
name,
|
|
60
|
+
state: item.state === 'resolved' ? 'resolved' : 'active',
|
|
61
|
+
evidencePinIds: Object.freeze(ids(item.evidencePinIds, references.evidencePinIds)),
|
|
62
|
+
checkpointIds: Object.freeze(ids(item.checkpointIds, references.checkpointIds)),
|
|
63
|
+
assertionIds: Object.freeze(ids(item.assertionIds, references.assertionIds)),
|
|
64
|
+
actionIds: Object.freeze(ids(item.actionIds, references.actionIds)),
|
|
65
|
+
createdAt,
|
|
66
|
+
updatedAt: iso(item.updatedAt, createdAt),
|
|
67
|
+
});
|
|
68
|
+
return branch.evidencePinIds.length || branch.checkpointIds.length || branch.assertionIds.length || branch.actionIds.length
|
|
69
|
+
? branch
|
|
70
|
+
: null;
|
|
71
|
+
}
|
|
72
|
+
export function caseInvestigationBranchReferences(input) {
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
evidencePinIds: new Set(input.evidencePins.map((item) => item.id)),
|
|
75
|
+
checkpointIds: new Set(input.evidencePins.flatMap((item) => item.checkpointId ? [item.checkpointId] : [])),
|
|
76
|
+
assertionIds: new Set(input.assertions.map((item) => item.id)),
|
|
77
|
+
actionIds: new Set(input.actions.map((item) => item.id)),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function normalizeCaseInvestigationBranches(value, fallback, references) {
|
|
81
|
+
if (!Array.isArray(value))
|
|
82
|
+
return [];
|
|
83
|
+
const byId = new Map();
|
|
84
|
+
for (const item of value.slice(0, MAX_CASE_INVESTIGATION_BRANCHES * 2)) {
|
|
85
|
+
const normalized = normalizeBranch(item, fallback, references);
|
|
86
|
+
if (!normalized)
|
|
87
|
+
continue;
|
|
88
|
+
const current = byId.get(normalized.id);
|
|
89
|
+
if (!current || normalized.updatedAt > current.updatedAt)
|
|
90
|
+
byId.set(normalized.id, normalized);
|
|
91
|
+
}
|
|
92
|
+
return [...byId.values()]
|
|
93
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
|
|
94
|
+
.slice(-MAX_CASE_INVESTIGATION_BRANCHES);
|
|
95
|
+
}
|
|
96
|
+
export function appendCaseInvestigationBranch(current, value, now, references) {
|
|
97
|
+
if (current.length >= MAX_CASE_INVESTIGATION_BRANCHES) {
|
|
98
|
+
throw new Error(`Each case is limited to ${MAX_CASE_INVESTIGATION_BRANCHES} investigation branches.`);
|
|
99
|
+
}
|
|
100
|
+
const item = record(value);
|
|
101
|
+
const branch = normalizeBranch({ ...item, id: freshId(), createdAt: now, updatedAt: now }, now, references);
|
|
102
|
+
if (!branch)
|
|
103
|
+
throw new Error('An investigation branch requires a name and at least one valid case reference.');
|
|
104
|
+
return normalizeCaseInvestigationBranches([...current, branch], now, references);
|
|
105
|
+
}
|
|
106
|
+
export function updateCaseInvestigationBranch(current, value, now, references) {
|
|
107
|
+
const patch = record(value);
|
|
108
|
+
const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
|
|
109
|
+
const index = current.findIndex((item) => item.id === id);
|
|
110
|
+
if (index < 0)
|
|
111
|
+
throw new Error('That investigation branch no longer exists.');
|
|
112
|
+
const existing = current[index];
|
|
113
|
+
if (!existing)
|
|
114
|
+
throw new Error('That investigation branch no longer exists.');
|
|
115
|
+
const normalized = normalizeBranch({
|
|
116
|
+
...existing,
|
|
117
|
+
...(Object.hasOwn(patch, 'name') ? { name: patch.name } : {}),
|
|
118
|
+
...(Object.hasOwn(patch, 'state') ? { state: patch.state } : {}),
|
|
119
|
+
...(Object.hasOwn(patch, 'evidencePinIds') ? { evidencePinIds: patch.evidencePinIds } : {}),
|
|
120
|
+
...(Object.hasOwn(patch, 'checkpointIds') ? { checkpointIds: patch.checkpointIds } : {}),
|
|
121
|
+
...(Object.hasOwn(patch, 'assertionIds') ? { assertionIds: patch.assertionIds } : {}),
|
|
122
|
+
...(Object.hasOwn(patch, 'actionIds') ? { actionIds: patch.actionIds } : {}),
|
|
123
|
+
id,
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
}, now, references);
|
|
126
|
+
if (!normalized)
|
|
127
|
+
throw new Error('An investigation branch requires a name and at least one valid case reference.');
|
|
128
|
+
const next = [...current];
|
|
129
|
+
next[index] = normalized;
|
|
130
|
+
return normalizeCaseInvestigationBranches(next, now, references);
|
|
131
|
+
}
|
|
132
|
+
export function mergeCaseInvestigationBranches(local, imported, fallback, references) {
|
|
133
|
+
return normalizeCaseInvestigationBranches([...local, ...imported], fallback, references);
|
|
134
|
+
}
|