@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
@@ -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' },
@@ -1,6 +1,6 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, 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";
3
+ import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
4
4
  // Forward-version policy (two distinct guarantees):
5
5
  // - A locally-stored envelope that declares a version greater than this is
6
6
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -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";
@@ -1,9 +1,11 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
3
  import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
4
- 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";
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
  // ---------------------------------------------------------------------------