@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.
Files changed (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. 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 = 3;
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 === true,
245
- faviconNearMatch: item.faviconNearMatch === true,
246
- reusesOfficialAssets: item.reusesOfficialAssets === true,
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: normalizeRelationship(item.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 session = normalizeBulkSession(candidate);
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 session = normalizeBulkSession(candidate);
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 (!existing)
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({
@@ -1,3 +1,7 @@
1
+ /** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
2
+ export function normalizeBulkPresentationSortKey(key) {
3
+ return key === 'opportunity' ? 'risk' : key;
4
+ }
1
5
  const CONFIDENCE_RANK = {
2
6
  low: 0,
3
7
  medium: 1,
@@ -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 { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, 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_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
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
+ }