@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -1,8 +1,11 @@
1
1
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
2
+ import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.js";
3
+ import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.js";
2
4
  import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
3
5
  const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
4
6
  const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
5
7
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
8
+ export const MAX_CASE_INPUT_RECORDS = 2_000;
6
9
  function assignUniqueIds(cases) {
7
10
  const used = new Set();
8
11
  for (const record of [...cases].sort((a, b) => a.domain.localeCompare(b.domain))) {
@@ -28,11 +31,16 @@ function assignUniqueIds(cases) {
28
31
  */
29
32
  export function normalizeCaseStore(raw) {
30
33
  const now = new Date().toISOString();
34
+ const sourceVersion = parseStoreVersion(raw);
35
+ const acceptsBrandProfileIds = Array.isArray(raw)
36
+ || sourceVersion === CASE_SCHEMA_VERSION;
31
37
  const byDomain = new Map();
32
- for (const item of asCaseList(raw)) {
33
- const normalized = normalizeCase(item, undefined, now);
38
+ for (const item of boundedCaseList(raw).items) {
39
+ const normalized = normalizeCase(item, undefined, now, Array.isArray(raw) ? CASE_SCHEMA_VERSION : sourceVersion);
34
40
  if (!normalized)
35
41
  continue;
42
+ if (!acceptsBrandProfileIds)
43
+ normalized.brandProfileIds = [];
36
44
  const existing = byDomain.get(normalized.domain);
37
45
  if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
38
46
  byDomain.set(normalized.domain, normalized);
@@ -64,6 +72,13 @@ function asCaseList(raw) {
64
72
  return cases;
65
73
  return [];
66
74
  }
75
+ function boundedCaseList(raw) {
76
+ const source = asCaseList(raw);
77
+ return {
78
+ items: source.slice(0, MAX_CASE_INPUT_RECORDS),
79
+ omitted: Math.max(0, source.length - MAX_CASE_INPUT_RECORDS),
80
+ };
81
+ }
67
82
  /**
68
83
  * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
69
84
  * @param {string} [nowIso]
@@ -82,7 +97,7 @@ function importScalar(value, valid) {
82
97
  * @param {unknown} raw
83
98
  * @returns {ImportPatch | null}
84
99
  */
85
- function extractImportPatch(raw) {
100
+ function extractImportPatch(raw, importedVersion) {
86
101
  const record = objectRecord(raw);
87
102
  const domain = normalizeDomain(record.domain);
88
103
  if (!domain)
@@ -90,8 +105,16 @@ function extractImportPatch(raw) {
90
105
  const importFallback = isoOrNull(record.updatedAt) || isoOrNull(record.createdAt) || null;
91
106
  const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
92
107
  const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
93
- const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback);
108
+ const evidencePins = normalizeCaseEvidencePins(record.evidencePins, normalizedFallback, {
109
+ allowCertificateObservation: importedVersion >= 11,
110
+ });
94
111
  const pinIds = new Set(evidencePins.map((item) => item.id));
112
+ const actions = normalizeCaseActions(record.actions, normalizedFallback);
113
+ const assertions = normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds);
114
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
115
+ const brandProfileReferences = importedVersion >= 12
116
+ ? inspectCaseBrandProfileIds(record.brandProfileIds)
117
+ : { ids: [], omitted: 0 };
95
118
  return {
96
119
  domain,
97
120
  rawId: typeof record.id === 'string' ? record.id : null,
@@ -100,14 +123,23 @@ function extractImportPatch(raw) {
100
123
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
101
124
  ? normalizeReviewReasonCode(record.reviewReasonCode)
102
125
  : undefined,
126
+ brandProfileIds: brandProfileReferences.ids,
127
+ brandProfileReferencesOmitted: brandProfileReferences.omitted,
103
128
  source: importScalar(record.source, SOURCE_VALUES),
104
- evidenceHistory: normalizeEvidenceHistory(rawEvidence, { source: 'import', fallback: importFallback }),
129
+ evidenceHistory: normalizeEvidenceHistory(rawEvidence, {
130
+ source: 'import',
131
+ fallback: importFallback,
132
+ sourceVersion: importedVersion,
133
+ }),
105
134
  evidencePins,
106
135
  decisions: normalizeCaseDecisions(record.decisions, normalizedFallback, pinIds),
107
- actions: normalizeCaseActions(record.actions, normalizedFallback),
108
- assertions: normalizeCaseAssertions(record.assertions, normalizedFallback, pinIds),
136
+ actions,
137
+ assertions,
109
138
  manualTrail: normalizeCaseManualTrail(record.manualTrail, normalizedFallback),
110
139
  sightings: normalizeCaseSightings(record.sightings, normalizedFallback, pinIds),
140
+ branches: importedVersion >= 11
141
+ ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences)
142
+ : [],
111
143
  tags: normalizeTags(record.tags),
112
144
  // Imported notes fall back only to the imported record's own timestamps
113
145
  // (never "now"), so a timestamp-less note gets a stable, deterministic time
@@ -143,6 +175,7 @@ function caseFromPatch(patch, now) {
143
175
  status: patch.status ?? DEFAULT_STATUS,
144
176
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
145
177
  reviewReasonCode: patch.reviewReasonCode ?? null,
178
+ brandProfileIds: patch.brandProfileIds,
146
179
  tags: patch.tags,
147
180
  notes: patch.notes,
148
181
  source: patch.source ?? DEFAULT_SOURCE,
@@ -153,6 +186,7 @@ function caseFromPatch(patch, now) {
153
186
  assertions: patch.assertions,
154
187
  manualTrail: patch.manualTrail,
155
188
  sightings: patch.sightings,
189
+ branches: patch.branches,
156
190
  createdAt: patch.createdAt || patch.updatedAt || now,
157
191
  updatedAt: patch.updatedAt || patch.createdAt || now,
158
192
  };
@@ -171,24 +205,30 @@ function applyImportPatch(local, patch) {
171
205
  const fallback = patch.updatedAt || local.updatedAt;
172
206
  const evidencePins = mergeCaseEvidencePins(local.evidencePins, patch.evidencePins, fallback);
173
207
  const pinIds = new Set(evidencePins.map((item) => item.id));
174
- return {
175
- ...local,
176
- status: patch.status !== undefined && importNewer ? patch.status : local.status,
177
- disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
178
- reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
179
- source: patch.source !== undefined && importNewer ? patch.source : local.source,
180
- evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
181
- evidencePins,
182
- decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
183
- actions: mergeCaseActions(local.actions, patch.actions, fallback),
184
- assertions: mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds),
185
- manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
186
- sightings: mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds),
187
- tags: normalizeTags([...local.tags, ...patch.tags]),
188
- notes: unionNotes(local.notes, patch.notes),
189
- createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
190
- updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
191
- };
208
+ const actions = mergeCaseActions(local.actions, patch.actions, fallback);
209
+ const assertions = mergeCaseAssertions(local.assertions, patch.assertions, fallback, pinIds);
210
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
211
+ const brandProfileReferences = unionCaseBrandProfileIds(local.brandProfileIds, patch.brandProfileIds);
212
+ return { record: {
213
+ ...local,
214
+ status: patch.status !== undefined && importNewer ? patch.status : local.status,
215
+ disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
216
+ reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
217
+ brandProfileIds: brandProfileReferences.ids,
218
+ source: patch.source !== undefined && importNewer ? patch.source : local.source,
219
+ evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
220
+ evidencePins,
221
+ decisions: mergeCaseDecisions(local.decisions, patch.decisions, fallback, pinIds),
222
+ actions,
223
+ assertions,
224
+ manualTrail: mergeCaseManualTrail(local.manualTrail, patch.manualTrail, fallback),
225
+ sightings: mergeCaseSightings(local.sightings, patch.sightings, fallback, pinIds),
226
+ branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
227
+ tags: normalizeTags([...local.tags, ...patch.tags]),
228
+ notes: unionNotes(local.notes, patch.notes),
229
+ createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
230
+ updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
231
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted };
192
232
  }
193
233
  function pickFreeId(preferred, domain, used) {
194
234
  const wanted = safeId(preferred);
@@ -208,7 +248,7 @@ function pickFreeId(preferred, domain, used) {
208
248
  * reinterpreted.
209
249
  * @param {CaseRecord[]} localCases
210
250
  * @param {unknown} importedRaw
211
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number }}
251
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number }}
212
252
  */
213
253
  export function mergeCases(localCases, importedRaw) {
214
254
  const importedVersion = parseStoreVersion(importedRaw);
@@ -221,21 +261,26 @@ export function mergeCases(localCases, importedRaw) {
221
261
  throw new Error(`Expected a WHOISleuth case export using schema ${CASE_IMPORT_VERSIONS.join(' or ')}.`);
222
262
  }
223
263
  const local = normalizeCaseStore(localCases).cases;
264
+ const supportedImportedVersion = importedVersion ?? 0;
224
265
  const byDomain = new Map(local.map((item) => [item.domain, item]));
225
266
  const usedIds = new Set(local.map((item) => item.id));
226
267
  let added = 0;
227
268
  let updated = 0;
228
- let skipped = 0;
269
+ const imported = boundedCaseList(importedRaw);
270
+ let skipped = imported.omitted;
271
+ let brandProfileReferencesOmitted = 0;
229
272
  const now = new Date().toISOString();
230
- for (const item of asCaseList(importedRaw)) {
231
- const patch = extractImportPatch(item);
273
+ for (const item of imported.items) {
274
+ const patch = extractImportPatch(item, supportedImportedVersion);
232
275
  if (!patch) {
233
276
  skipped += 1;
234
277
  continue;
235
278
  }
236
279
  const existing = byDomain.get(patch.domain);
237
280
  if (existing) {
238
- byDomain.set(patch.domain, applyImportPatch(existing, patch));
281
+ const merged = applyImportPatch(existing, patch);
282
+ byDomain.set(patch.domain, merged.record);
283
+ brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
239
284
  updated += 1;
240
285
  }
241
286
  else if (byDomain.size < MAX_CASES) {
@@ -243,11 +288,18 @@ export function mergeCases(localCases, importedRaw) {
243
288
  record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
244
289
  usedIds.add(record.id);
245
290
  byDomain.set(patch.domain, record);
291
+ brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
246
292
  added += 1;
247
293
  }
248
294
  else {
249
295
  skipped += 1;
250
296
  }
251
297
  }
252
- return { cases: normalizeCaseStore([...byDomain.values()]).cases, added, updated, skipped };
298
+ return {
299
+ cases: normalizeCaseStore([...byDomain.values()]).cases,
300
+ added,
301
+ updated,
302
+ skipped,
303
+ brandProfileReferencesOmitted,
304
+ };
253
305
  }
@@ -1,6 +1,6 @@
1
1
  import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
2
- export const CASE_SCHEMA_VERSION = 10;
3
- export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
2
+ export const CASE_SCHEMA_VERSION = 12;
3
+ export const CASE_IMPORT_VERSIONS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, CASE_SCHEMA_VERSION];
4
4
  export const MAX_CASES = 500;
5
5
  export const MAX_NOTES_PER_CASE = 50;
6
6
  export const MAX_NOTE_LENGTH = 2000;
@@ -17,6 +17,8 @@ export const MAX_EVIDENCE_TITLE_LENGTH = 200;
17
17
  export const MAX_EVIDENCE_DETAIL_LENGTH = 200;
18
18
  export const MAX_EVIDENCE_CHANGES = 40;
19
19
  export const MAX_CASE_STORE_BYTES = 4 * 1024 * 1024;
20
+ export const MAX_CASE_BRAND_PROFILE_IDS = 8;
21
+ export const MAX_CASE_BRAND_PROFILE_ID_CANDIDATES = 32;
20
22
  export const CASE_STATUSES = [
21
23
  { value: 'new', label: 'New' },
22
24
  { value: 'reviewing', label: 'Reviewing' },
@@ -3,5 +3,7 @@
3
3
  // testable while existing consumers keep one compatibility import.
4
4
  export * from "./case-record-contracts.js";
5
5
  export * from "./case-record-core.js";
6
+ export * from "./case-brand-profile-references.js";
6
7
  export * from "./case-evidence-model.js";
7
8
  export * from "./case-record-operations.js";
9
+ export * from "./case-investigation-branch-model.js";
@@ -3,7 +3,9 @@
3
3
  import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
4
4
  import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.js";
5
5
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
6
+ import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.js";
6
7
  import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
8
+ import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.js";
7
9
  // ---------------------------------------------------------------------------
8
10
  // Case normalization
9
11
  // ---------------------------------------------------------------------------
@@ -16,10 +18,14 @@ function inferCaptureSource(caseSource) {
16
18
  }
17
19
  // Builds a case's bounded current-schema evidence history. Uses a lenient
18
20
  // local fallback timestamp so recoverable current data always loads.
19
- function normalizeCaseEvidence(record, createdAt, updatedAt, now) {
21
+ function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion) {
20
22
  const localFallback = updatedAt || createdAt || now;
21
23
  if (Array.isArray(record.evidenceHistory)) {
22
- return normalizeEvidenceHistory(record.evidenceHistory, { source: DEFAULT_EVIDENCE_SOURCE, fallback: localFallback });
24
+ return normalizeEvidenceHistory(record.evidenceHistory, {
25
+ source: DEFAULT_EVIDENCE_SOURCE,
26
+ fallback: localFallback,
27
+ ...(sourceVersion === undefined ? {} : { sourceVersion }),
28
+ });
23
29
  }
24
30
  return [];
25
31
  }
@@ -34,7 +40,7 @@ function normalizeCaseEvidence(record, createdAt, updatedAt, now) {
34
40
  * @param {string} [nowIso]
35
41
  * @returns {CaseRecord | null}
36
42
  */
37
- export function normalizeCase(raw, existing, nowIso) {
43
+ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
38
44
  const now = nowIso || new Date().toISOString();
39
45
  const record = objectRecord(raw);
40
46
  const domain = normalizeDomain(existing ? existing.domain : record.domain);
@@ -44,22 +50,27 @@ export function normalizeCase(raw, existing, nowIso) {
44
50
  const updatedAt = isoOrNow(record.updatedAt, now);
45
51
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt);
46
52
  const pinIds = new Set(evidencePins.map((item) => item.id));
53
+ const actions = normalizeCaseActions(record.actions, updatedAt);
54
+ const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds);
55
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
47
56
  return {
48
57
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
49
58
  domain,
50
59
  status: normalizeStatus(record.status),
51
60
  disposition: normalizeDisposition(record.disposition),
52
61
  reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
62
+ brandProfileIds: normalizeCaseBrandProfileIds(record.brandProfileIds),
53
63
  tags: normalizeTags(record.tags),
54
64
  notes: normalizeNotes(record.notes, now),
55
65
  source: normalizeSource(record.source),
56
- evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now),
66
+ evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion),
57
67
  evidencePins,
58
68
  decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds),
59
- actions: normalizeCaseActions(record.actions, updatedAt),
60
- assertions: normalizeCaseAssertions(record.assertions, updatedAt, pinIds),
69
+ actions,
70
+ assertions,
61
71
  manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt),
62
72
  sightings: normalizeCaseSightings(record.sightings, updatedAt, pinIds),
73
+ branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences),
63
74
  createdAt,
64
75
  updatedAt,
65
76
  };
@@ -82,12 +93,20 @@ export function createCase(input, nowIso) {
82
93
  ? appendCaseEvidencePin([], input.evidencePin, now)
83
94
  : [];
84
95
  const pinIds = new Set(evidencePins.map((item) => item.id));
96
+ const actions = input.action !== undefined
97
+ ? appendCaseAction([], input.action, now)
98
+ : [];
99
+ const assertions = input.assertion !== undefined
100
+ ? appendCaseAssertion([], input.assertion, now)
101
+ : [];
102
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
85
103
  return {
86
104
  id: makeId(),
87
105
  domain,
88
106
  status: normalizeStatus(input.status),
89
107
  disposition: normalizeDisposition(input.disposition),
90
108
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
109
+ brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
91
110
  tags: normalizeTags(input.tags),
92
111
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
93
112
  source,
@@ -99,18 +118,17 @@ export function createCase(input, nowIso) {
99
118
  decisions: input.decision !== undefined
100
119
  ? appendCaseDecision([], input.decision, now)
101
120
  : [],
102
- actions: input.action !== undefined
103
- ? appendCaseAction([], input.action, now)
104
- : [],
105
- assertions: input.assertion !== undefined
106
- ? appendCaseAssertion([], input.assertion, now)
107
- : [],
121
+ actions,
122
+ assertions,
108
123
  manualTrail: input.trailEvent !== undefined
109
124
  ? appendCaseManualTrailEvent([], input.trailEvent, now)
110
125
  : [],
111
126
  sightings: input.sighting !== undefined
112
127
  ? appendCaseSighting([], input.sighting, now, pinIds)
113
128
  : [],
129
+ branches: input.branch !== undefined
130
+ ? appendCaseInvestigationBranch([], input.branch, now, branchReferences)
131
+ : [],
114
132
  createdAt: now,
115
133
  updatedAt: now,
116
134
  };
@@ -196,6 +214,14 @@ export function updateCase(cases, id, patch, nowIso) {
196
214
  if (patch.assertionUpdate !== undefined) {
197
215
  assertions = updateCaseAssertion(assertions, patch.assertionUpdate, now, pinIds);
198
216
  }
217
+ const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
218
+ let branches = normalizeCaseInvestigationBranches(current.branches ?? [], current.updatedAt, branchReferences);
219
+ if (patch.branch !== undefined) {
220
+ branches = appendCaseInvestigationBranch(branches, patch.branch, now, branchReferences);
221
+ }
222
+ if (patch.branchUpdate !== undefined) {
223
+ branches = updateCaseInvestigationBranch(branches, patch.branchUpdate, now, branchReferences);
224
+ }
199
225
  let manualTrail = current.manualTrail;
200
226
  if (patch.trailEvent !== undefined) {
201
227
  manualTrail = appendCaseManualTrailEvent(current.manualTrail, patch.trailEvent, now);
@@ -211,6 +237,9 @@ export function updateCase(cases, id, patch, nowIso) {
211
237
  reviewReasonCode: patch.reviewReasonCode !== undefined
212
238
  ? normalizeReviewReasonCode(patch.reviewReasonCode)
213
239
  : current.reviewReasonCode ?? null,
240
+ brandProfileIds: patch.brandProfileIds !== undefined
241
+ ? assertCaseBrandProfileIds(patch.brandProfileIds)
242
+ : current.brandProfileIds,
214
243
  tags: patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags,
215
244
  source,
216
245
  evidenceHistory,
@@ -220,6 +249,7 @@ export function updateCase(cases, id, patch, nowIso) {
220
249
  assertions,
221
250
  manualTrail,
222
251
  sightings,
252
+ branches,
223
253
  notes,
224
254
  updatedAt: now,
225
255
  };
@@ -11,19 +11,21 @@
11
11
  import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.js";
12
12
  import { httpSecurityHeaderLabel } from "./http-summary.js";
13
13
  import { analystInteroperabilityTags } from "../../../../lib/analyst-taxonomy.mjs";
14
+ import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../../../lib/portable-generator.mjs";
14
15
  import { CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.js";
16
+ import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.js";
15
17
  // ---------------------------------------------------------------------------
16
18
  // Constants
17
19
  // ---------------------------------------------------------------------------
18
20
  export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
19
- export const CASE_REPORT_SCHEMA_VERSION = 6;
20
- const APPLICATION_NAME = 'WHOISleuth';
21
+ export const CASE_REPORT_SCHEMA_VERSION = 8;
21
22
  const LIMITATIONS_TEXT = [
22
23
  'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
23
24
  'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
24
25
  'Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated.',
25
26
  'Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes.',
26
27
  'Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably.',
28
+ 'Brand Profile references record an explicit analyst-selected association only; they do not establish ownership, attribution, intent, safety, or maliciousness.',
27
29
  'Generated locally in the browser. Review the package before sharing it.',
28
30
  ].join(' ');
29
31
  function escapeMarkdownInline(text) {
@@ -139,6 +141,8 @@ function pickKnownSnapshotFields(snapshot) {
139
141
  idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
140
142
  pageBaselineMatch: snapshot.pageBaselineMatch ?? null,
141
143
  hasActiveBrandProfile: snapshot.hasActiveBrandProfile ?? null,
144
+ profileContextState: snapshot.profileContextState ?? null,
145
+ profileContextLimitation: snapshot.profileContextLimitation ?? null,
142
146
  mutationTypes: Array.isArray(snapshot.mutationTypes) ? [...snapshot.mutationTypes] : [],
143
147
  };
144
148
  }
@@ -158,6 +162,7 @@ export function buildCaseReport(caseRecord, options = {}) {
158
162
  const { generatedAt } = options;
159
163
  const includeNotes = options.includeNotes === true;
160
164
  const now = generatedAt || new Date().toISOString();
165
+ const generator = buildPortableGeneratorMetadata(options.applicationVersion);
161
166
  // --- Build JSON report ---
162
167
  const timelineEntries = [];
163
168
  if (Array.isArray(caseRecord.evidenceHistory) && caseRecord.evidenceHistory.length > 0) {
@@ -207,13 +212,14 @@ export function buildCaseReport(caseRecord, options = {}) {
207
212
  schema: CASE_REPORT_SCHEMA,
208
213
  schemaVersion: CASE_REPORT_SCHEMA_VERSION,
209
214
  generatedAt: now,
210
- application: { name: APPLICATION_NAME },
215
+ application: generator,
211
216
  case: {
212
217
  id: caseRecord.id,
213
218
  domain: caseRecord.domain,
214
219
  status: caseRecord.status,
215
220
  disposition: caseRecord.disposition,
216
221
  reviewReasonCode: caseRecord.reviewReasonCode ?? null,
222
+ brandProfileIds: normalizeCaseBrandProfileIds(caseRecord.brandProfileIds),
217
223
  interoperabilityTags: analystInteroperabilityTags(caseRecord.disposition, caseRecord.reviewReasonCode),
218
224
  tags: Array.isArray(caseRecord.tags) ? [...caseRecord.tags] : [],
219
225
  source: caseRecord.source,
@@ -242,11 +248,18 @@ export function buildCaseReport(caseRecord, options = {}) {
242
248
  })),
243
249
  manualTrail: caseRecord.manualTrail.map((item) => ({ ...item })),
244
250
  sightings: caseRecord.sightings.map((item) => ({ ...item, limitations: [...item.limitations] })),
251
+ branches: (caseRecord.branches ?? []).map((item) => ({
252
+ ...item,
253
+ evidencePinIds: [...item.evidencePinIds],
254
+ checkpointIds: [...item.checkpointIds],
255
+ assertionIds: [...item.assertionIds],
256
+ actionIds: [...item.actionIds],
257
+ })),
245
258
  },
246
259
  limitations: LIMITATIONS_TEXT,
247
260
  };
248
261
  // --- Build Markdown ---
249
- const md = buildMarkdown(json);
262
+ const md = buildMarkdown(json, options.includeAttribution !== false);
250
263
  return { json, markdown: md };
251
264
  }
252
265
  // ---------------------------------------------------------------------------
@@ -257,7 +270,7 @@ export function buildCaseReport(caseRecord, options = {}) {
257
270
  * @param {object} report
258
271
  * @returns {string}
259
272
  */
260
- function buildMarkdown(report) {
273
+ function buildMarkdown(report, includeAttribution) {
261
274
  const lines = [];
262
275
  // Title
263
276
  const domain = escapeMarkdownInline(report.case.domain || 'unknown');
@@ -276,6 +289,7 @@ function buildMarkdown(report) {
276
289
  lines.push(`| Disposition | ${escapeMarkdownInline(report.case.disposition)} |`);
277
290
  if (report.case.reviewReasonCode)
278
291
  lines.push(`| Review reason | ${escapeMarkdownInline(report.case.reviewReasonCode.replaceAll('_', ' '))} |`);
292
+ lines.push(`| Brand Profile references | ${report.case.brandProfileIds.length ? escapeMarkdownInline(report.case.brandProfileIds.join(', ')) : 'None'} |`);
279
293
  lines.push(`| Source | ${escapeMarkdownInline(report.case.source)} |`);
280
294
  lines.push(`| Opened | ${escapeMarkdownInline(report.case.openedAt)} |`);
281
295
  lines.push(`| Updated | ${escapeMarkdownInline(report.case.updatedAt)} |`);
@@ -292,6 +306,9 @@ function buildMarkdown(report) {
292
306
  lines.push(`- **Availability:** ${escapeMarkdownInline(formatReportValue(a.availability))}`);
293
307
  lines.push(`- **Risk score:** ${escapeMarkdownInline(formatReportValue(a.riskScore))}`);
294
308
  lines.push(`- **Risk model:** ${a.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.riskModelVersion))}`}`);
309
+ lines.push(`- **Brand Profile context:** ${escapeMarkdownInline(formatReportValue(a.profileContextState))}`);
310
+ if (a.profileContextLimitation)
311
+ lines.push(`- **Profile-context limitation:** ${escapeMarkdownInline(a.profileContextLimitation)}`);
295
312
  lines.push(`- **Opportunity model:** ${a.opportunityModelVersion == null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.opportunityModelVersion))}`}`);
296
313
  lines.push(`- **Registrar:** ${escapeMarkdownInline(formatReportValue(a.registrar))}`);
297
314
  lines.push(`- **Website activity:** ${escapeMarkdownInline(formatReportValue(a.activityStatus))}`);
@@ -342,6 +359,9 @@ function buildMarkdown(report) {
342
359
  lines.push(`- Availability: ${escapeMarkdownInline(formatReportValue(snap.availability))}`);
343
360
  lines.push(`- Risk score: ${escapeMarkdownInline(formatReportValue(snap.riskScore))}`);
344
361
  lines.push(`- Risk model: ${snap.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(snap.riskModelVersion))}`}`);
362
+ lines.push(`- Brand Profile context: ${escapeMarkdownInline(formatReportValue(snap.profileContextState))}`);
363
+ if (snap.profileContextLimitation)
364
+ lines.push(`- Profile-context limitation: ${escapeMarkdownInline(snap.profileContextLimitation)}`);
345
365
  lines.push(`- Registrar: ${escapeMarkdownInline(formatReportValue(snap.registrar))}`);
346
366
  lines.push(`- Website activity: ${escapeMarkdownInline(formatReportValue(snap.activityStatus))}`);
347
367
  if (snap.httpResponseStatus != null)
@@ -411,7 +431,7 @@ function buildMarkdown(report) {
411
431
  lines.push('## Analyst Decision Packet');
412
432
  lines.push('');
413
433
  const response = report.analystResponse;
414
- if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length) {
434
+ if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.branches.length) {
415
435
  lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, case actions, or manual investigation steps recorded.');
416
436
  lines.push('');
417
437
  }
@@ -511,6 +531,15 @@ function buildMarkdown(report) {
511
531
  lines.push(` Contact limitations: ${escapeMarkdownInline(action.contactLimitations.join('; '))}`);
512
532
  }
513
533
  lines.push('');
534
+ lines.push('### Investigation branches');
535
+ lines.push('');
536
+ if (!response.branches.length)
537
+ lines.push('No named investigation branches recorded.');
538
+ for (const branch of response.branches) {
539
+ lines.push(`- **${escapeMarkdownInline(branch.name)}** (${escapeMarkdownInline(branch.state)}; updated ${escapeMarkdownInline(branch.updatedAt)})`);
540
+ lines.push(` References: ${branch.evidencePinIds.length} evidence pins; ${branch.checkpointIds.length} checkpoints; ${branch.assertionIds.length} assertions; ${branch.actionIds.length} actions.`);
541
+ }
542
+ lines.push('');
514
543
  lines.push('### Explicit investigation trail');
515
544
  lines.push('');
516
545
  if (!response.manualTrail.length)
@@ -542,6 +571,10 @@ function buildMarkdown(report) {
542
571
  lines.push('');
543
572
  lines.push(LIMITATIONS_TEXT);
544
573
  lines.push('');
574
+ if (includeAttribution) {
575
+ lines.push(portableGeneratorAttribution(report.application));
576
+ lines.push('');
577
+ }
545
578
  return lines.join('\n');
546
579
  }
547
580
  // ---------------------------------------------------------------------------
@@ -89,6 +89,7 @@ const ASSERTION_EXTERNAL_ENTITY_TYPES = new Set(CASE_ASSERTION_EXTERNAL_ENTITY_T
89
89
  const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
90
90
  const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
91
91
  const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
92
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
92
93
  function record(value) {
93
94
  return value && typeof value === 'object' && !Array.isArray(value)
94
95
  ? value
@@ -159,6 +160,54 @@ function sourceSchema(value) {
159
160
  : null;
160
161
  return collection && schema && version !== null ? { collection, schema, version } : null;
161
162
  }
163
+ function certificateObservation(value, pin) {
164
+ const item = record(value);
165
+ if (!Object.keys(item).length)
166
+ return null;
167
+ const keys = new Set([
168
+ 'eventId',
169
+ 'logId',
170
+ 'certificateSha256',
171
+ 'issuer',
172
+ 'notAfter',
173
+ 'dnsNameCount',
174
+ 'namesComplete',
175
+ ]);
176
+ if (Object.keys(item).some((key) => !keys.has(key)))
177
+ return null;
178
+ if (pin.sourceSchema?.collection !== 'external_observations'
179
+ || pin.sourceSchema.schema !== 'whoisleuth.certificate-observation-rows'
180
+ || (pin.field !== 'certificateSha256' && pin.field !== 'fingerprintSha256'))
181
+ return null;
182
+ const eventId = text(item.eventId, 64);
183
+ const logId = text(item.logId, 200);
184
+ const certificateSha256 = text(item.certificateSha256, 64).toLowerCase();
185
+ const issuer = item.issuer === null ? null : text(item.issuer, 160) || null;
186
+ const notAfter = item.notAfter === null ? null : optionalIso(item.notAfter);
187
+ const dnsNameCount = typeof item.dnsNameCount === 'number'
188
+ && Number.isSafeInteger(item.dnsNameCount)
189
+ && item.dnsNameCount >= 1
190
+ && item.dnsNameCount <= 100
191
+ ? item.dnsNameCount
192
+ : null;
193
+ if (!SAFE_ID_RE.test(eventId)
194
+ || !logId
195
+ || !SHA256_RE.test(certificateSha256)
196
+ || certificateSha256 !== pin.value.toLowerCase()
197
+ || dnsNameCount === null
198
+ || typeof item.namesComplete !== 'boolean'
199
+ || (item.notAfter !== null && notAfter === null))
200
+ return null;
201
+ return {
202
+ eventId,
203
+ logId,
204
+ certificateSha256,
205
+ issuer,
206
+ notAfter,
207
+ dnsNameCount,
208
+ namesComplete: item.namesComplete,
209
+ };
210
+ }
162
211
  function uniqueIds(value, validIds) {
163
212
  if (!Array.isArray(value))
164
213
  return [];
@@ -196,25 +245,27 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
196
245
  }
197
246
  return [...output].map(([evidencePinId, stance]) => ({ evidencePinId, stance }));
198
247
  }
199
- function normalizePin(raw, fallback) {
248
+ function normalizePin(raw, fallback, options = {}) {
200
249
  const item = record(raw);
201
250
  const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
202
251
  const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
203
252
  if (!label || !value)
204
253
  return null;
205
254
  const createdAt = iso(item.createdAt, fallback);
206
- return {
255
+ const normalizedSourceSchema = sourceSchema(item.sourceSchema);
256
+ const normalizedField = text(item.field, 120) || null;
257
+ const normalized = {
207
258
  id: safeId(item.id, 'pin', { label, value, createdAt }),
208
259
  checkpointId: typeof item.checkpointId === 'string' && SAFE_ID_RE.test(item.checkpointId)
209
260
  ? item.checkpointId
210
261
  : null,
211
- field: text(item.field, 120) || null,
262
+ field: normalizedField,
212
263
  category: text(item.category, 80) || null,
213
264
  label,
214
265
  value,
215
266
  source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
216
267
  sourceState: text(item.sourceState, 40) || null,
217
- sourceSchema: sourceSchema(item.sourceSchema),
268
+ sourceSchema: normalizedSourceSchema,
218
269
  observedAt: iso(item.observedAt, createdAt),
219
270
  collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
220
271
  ? item.collectionDepth
@@ -230,13 +281,21 @@ function normalizePin(raw, fallback) {
230
281
  limitations: limitations(item.limitations),
231
282
  createdAt,
232
283
  };
233
- }
234
- export function normalizeCaseEvidencePins(raw, fallback) {
284
+ normalized.certificateObservation = options.allowCertificateObservation === false
285
+ ? null
286
+ : certificateObservation(item.certificateObservation, {
287
+ field: normalizedField,
288
+ value,
289
+ sourceSchema: normalizedSourceSchema,
290
+ });
291
+ return normalized;
292
+ }
293
+ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
235
294
  if (!Array.isArray(raw))
236
295
  return [];
237
296
  const byId = new Map();
238
297
  for (const item of raw.slice(0, MAX_CASE_EVIDENCE_PINS * 2)) {
239
- const normalized = normalizePin(item, fallback);
298
+ const normalized = normalizePin(item, fallback, options);
240
299
  if (normalized && !byId.has(normalized.id))
241
300
  byId.set(normalized.id, normalized);
242
301
  }