@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,221 @@
1
+ import { PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
2
+ import { caseStatusIsClosed } from "./case-record-decisions.mjs";
3
+ const PRESERVE = Object.freeze({
4
+ durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
5
+ });
6
+ const PUBLIC_TRANSFORM = Object.freeze({ ...PRESERVE, public: 'transform' });
7
+ const PUBLIC_EXCLUDE = Object.freeze({ ...PRESERVE, public: 'exclude' });
8
+ const SHARED_EXCLUDE = Object.freeze({ ...PRESERVE, trusted: 'exclude', public: 'exclude' });
9
+ const SHARED_REDACT = Object.freeze({ ...PRESERVE, trusted: 'redact', public: 'redact' });
10
+ const TRUSTED_REDACT_PUBLIC_EXCLUDE = Object.freeze({
11
+ ...PRESERVE, trusted: 'redact', public: 'exclude',
12
+ });
13
+ const PUBLIC_REDACT = Object.freeze({ ...PRESERVE, public: 'redact' });
14
+ function fieldRule(key, treatment, value, options = {}) {
15
+ const audienceExclusions = Object.freeze(Object.fromEntries(Object.entries(options.audienceExclusions ?? {}).map(([audience, exclusion]) => [
16
+ audience,
17
+ Object.freeze({ ...exclusion }),
18
+ ])));
19
+ for (const audience of ['internal', 'trusted', 'public']) {
20
+ const requiresExplanation = treatment[audience] === 'exclude' || treatment[audience] === 'redact';
21
+ if (requiresExplanation !== Boolean(audienceExclusions[audience])) {
22
+ throw new TypeError(`Case field ${key} must explain every redacted or excluded ${audience} treatment exactly once.`);
23
+ }
24
+ }
25
+ return Object.freeze({
26
+ key,
27
+ treatment,
28
+ value,
29
+ nestedSensitiveFields: Object.freeze([...(options.nestedSensitiveFields ?? [])]),
30
+ audienceExclusions,
31
+ });
32
+ }
33
+ function preservedField(key) {
34
+ return fieldRule(key, PRESERVE, (record) => record[key]);
35
+ }
36
+ const PUBLIC_OBSERVED_EFFECT_LIMITATION = 'Independent observed-effect review records were excluded from this public Case pack.';
37
+ const PUBLIC_CLOSURE_LIMITATION = 'Deliberate closure records were excluded from this public Case pack.';
38
+ function publicObservedEffectHistory(preV13HistoryUnavailable) {
39
+ return {
40
+ reviews: [],
41
+ omitted: 0,
42
+ preV13HistoryUnavailable,
43
+ limitations: [
44
+ ...(preV13HistoryUnavailable
45
+ ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.']
46
+ : []),
47
+ 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
48
+ PUBLIC_OBSERVED_EFFECT_LIMITATION,
49
+ ].sort(),
50
+ };
51
+ }
52
+ function publicClosureHistory(preV13HistoryUnavailable) {
53
+ return {
54
+ records: [],
55
+ omitted: 0,
56
+ preV13HistoryUnavailable,
57
+ limitations: [
58
+ ...(preV13HistoryUnavailable
59
+ ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.']
60
+ : []),
61
+ 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
62
+ PUBLIC_CLOSURE_LIMITATION,
63
+ ].sort(),
64
+ };
65
+ }
66
+ /**
67
+ * The complete current Case field policy. Browser persistence and ordinary
68
+ * exports deliberately share the `durable` projection. Case-pack audiences
69
+ * must classify every field here before a new Case property can compile.
70
+ */
71
+ const CASE_FIELD_RULES = Object.freeze({
72
+ id: preservedField('id'),
73
+ domain: preservedField('domain'),
74
+ title: fieldRule('title', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? '' : record.title ?? ''), {
75
+ audienceExclusions: {
76
+ trusted: { label: 'Case titles', order: 1, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
77
+ public: { label: 'Case titles', order: 1, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
78
+ },
79
+ }),
80
+ status: fieldRule('status', PUBLIC_TRANSFORM, (record, profile) => (profile === 'public' && caseStatusIsClosed(record.status) && record.closures.records.length
81
+ ? 'reviewing'
82
+ : record.status)),
83
+ disposition: preservedField('disposition'),
84
+ reviewReasonCode: fieldRule('reviewReasonCode', PRESERVE, (record) => record.reviewReasonCode ?? null),
85
+ brandProfileIds: fieldRule('brandProfileIds', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : [...record.brandProfileIds]), {
86
+ audienceExclusions: { public: { label: 'Brand Profile references', order: 2 } },
87
+ }),
88
+ tags: fieldRule('tags', PRESERVE, (record) => [...record.tags]),
89
+ notes: fieldRule('notes', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public'
90
+ ? []
91
+ : record.notes.map((item) => ({ ...item }))), {
92
+ audienceExclusions: {
93
+ trusted: { label: 'Case notes', order: 1 },
94
+ public: { label: 'Case notes', order: 1 },
95
+ },
96
+ }),
97
+ source: preservedField('source'),
98
+ evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
99
+ evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
100
+ decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
101
+ actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
102
+ ? []
103
+ : profile === 'trusted'
104
+ ? structuredClone(record.actions).map((item) => ({ ...item, recipient: '[redacted]' }))
105
+ : structuredClone(record.actions)), {
106
+ nestedSensitiveFields: ['recipient'],
107
+ audienceExclusions: {
108
+ trusted: { label: 'Recipient values', order: 2 },
109
+ public: { label: 'Actions and recipient values', order: 3 },
110
+ },
111
+ }),
112
+ assertions: fieldRule('assertions', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.assertions)), {
113
+ audienceExclusions: { public: { label: 'Analyst assertions', order: 4 } },
114
+ }),
115
+ manualTrail: fieldRule('manualTrail', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
116
+ ? structuredClone(record.manualTrail).map((item) => ({ ...item, target: null }))
117
+ : structuredClone(record.manualTrail)), {
118
+ nestedSensitiveFields: ['target'],
119
+ audienceExclusions: {
120
+ trusted: { label: 'Manual trail targets', order: 3 },
121
+ public: { label: 'Manual trail targets', order: 6 },
122
+ },
123
+ }),
124
+ sightings: fieldRule('sightings', PRESERVE, (record) => structuredClone(record.sightings)),
125
+ observedEffects: fieldRule('observedEffects', PUBLIC_REDACT, (record, profile) => (profile === 'public'
126
+ ? publicObservedEffectHistory(record.observedEffects.preV13HistoryUnavailable)
127
+ : structuredClone(record.observedEffects)), {
128
+ audienceExclusions: {
129
+ public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
130
+ },
131
+ }),
132
+ closures: fieldRule('closures', PUBLIC_REDACT, (record, profile) => (profile === 'public'
133
+ ? publicClosureHistory(record.closures.preV13HistoryUnavailable)
134
+ : structuredClone(record.closures)), {
135
+ audienceExclusions: {
136
+ public: { label: 'Independent observed-effect reviews and closure history', order: 8 },
137
+ },
138
+ }),
139
+ branches: fieldRule('branches', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? [] : structuredClone(record.branches ?? [])), {
140
+ audienceExclusions: { public: { label: 'Investigation branches', order: 5 } },
141
+ }),
142
+ attachments: fieldRule('attachments', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? undefined : structuredClone(record.attachments)), {
143
+ audienceExclusions: {
144
+ trusted: { label: 'Retained file references and provenance', order: 5, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
145
+ public: { label: 'Retained file references and provenance', order: 5, sinceVersion: INCIDENT_CASE_SCHEMA_VERSION },
146
+ },
147
+ }),
148
+ createdAt: preservedField('createdAt'),
149
+ updatedAt: preservedField('updatedAt'),
150
+ });
151
+ function projectCase(record, profile) {
152
+ const projected = {};
153
+ for (const key of Object.keys(CASE_FIELD_RULES)) {
154
+ const rule = CASE_FIELD_RULES[key];
155
+ const value = rule.value(record, profile);
156
+ if (value === undefined)
157
+ continue;
158
+ Object.defineProperty(projected, key, {
159
+ configurable: true,
160
+ enumerable: true,
161
+ value,
162
+ writable: true,
163
+ });
164
+ }
165
+ return projected;
166
+ }
167
+ export function projectCaseForDurableWrite(record) {
168
+ return projectCase(record, 'durable');
169
+ }
170
+ export function projectCaseForAudience(record, audience) {
171
+ return projectCase(record, audience);
172
+ }
173
+ /** Field names used by the bounded hidden-copy defence, derived from policy. */
174
+ export const CASE_AUDIENCE_SENSITIVE_FIELD_NAMES = Object.freeze([...new Set(Object.entries(CASE_FIELD_RULES).flatMap(([key, rule]) => {
175
+ const audienceTreatments = [rule.treatment.trusted, rule.treatment.public];
176
+ const sensitive = audienceTreatments.some((value) => value === 'exclude' || value === 'redact');
177
+ return sensitive ? [key, ...rule.nestedSensitiveFields] : [];
178
+ }))]);
179
+ export const CASE_RECORD_FIELD_NAMES = Object.freeze(Object.values(CASE_FIELD_RULES).map((rule) => rule.key));
180
+ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
181
+ internal: Object.freeze({ label: 'Raw upstream payloads and credentials are outside the case schema.', order: 1 }),
182
+ trusted: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 4 }),
183
+ public: Object.freeze({ label: 'Raw upstream payloads and credentials', order: 7 }),
184
+ });
185
+ function currentAudienceExclusions(audience, caseVersion) {
186
+ const entries = [
187
+ ...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
188
+ OUTSIDE_SCHEMA_EXCLUSIONS[audience],
189
+ ];
190
+ const orderByLabel = new Map();
191
+ for (const entry of entries) {
192
+ if (entry.sinceVersion !== undefined && caseVersion < entry.sinceVersion)
193
+ continue;
194
+ if (!entry.label || entry.label.length > 160 || entry.label.trim() !== entry.label
195
+ || !Number.isSafeInteger(entry.order) || entry.order < 1 || entry.order > 64) {
196
+ throw new TypeError('Case audience exclusion metadata is malformed or exceeds its bound.');
197
+ }
198
+ const prior = orderByLabel.get(entry.label);
199
+ orderByLabel.set(entry.label, Math.min(prior ?? entry.order, entry.order));
200
+ }
201
+ return Object.freeze([...orderByLabel]
202
+ .sort((left, right) => left[1] - right[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0))
203
+ .map(([label]) => label));
204
+ }
205
+ // Schema 12 is an immutable public compatibility contract and deliberately
206
+ // remains independent of the current field-policy projection.
207
+ const PUBLIC_V12_EXCLUSIONS = Object.freeze([
208
+ 'Case notes',
209
+ 'Brand Profile references',
210
+ 'Actions and recipient values',
211
+ 'Analyst assertions',
212
+ 'Investigation branches',
213
+ 'Manual trail targets',
214
+ 'Raw upstream payloads and credentials',
215
+ ]);
216
+ export function caseAudienceExclusions(audience, caseVersion) {
217
+ return audience === 'public' && caseVersion === PUBLIC_CASE_SCHEMA_VERSION
218
+ ? PUBLIC_V12_EXCLUSIONS
219
+ : currentAudienceExclusions(audience, caseVersion);
220
+ }
221
+ export { CASE_FIELD_RULES };
@@ -8,18 +8,19 @@
8
8
  // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
9
  // registry/web responses, contacts, cookies, screenshots, and authentication
10
10
  // data. Reports contain only the normalized case record.
11
- import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.mjs";
11
+ import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence } from "./case-model.mjs";
12
12
  import { httpSecurityHeaderLabel } from "./http-summary.mjs";
13
13
  import { analystInteroperabilityTags } from "../../lib/analyst-taxonomy.mjs";
14
14
  import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../lib/portable-generator.mjs";
15
15
  import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
16
16
  import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
17
- import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
17
+ import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
18
+ import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
18
19
  // ---------------------------------------------------------------------------
19
20
  // Constants
20
21
  // ---------------------------------------------------------------------------
21
22
  export { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION };
22
- const LIMITATIONS_TEXT = [
23
+ const PUBLISHED_V2_3_LIMITATIONS = Object.freeze([
23
24
  'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
24
25
  'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
25
26
  'Absence of a signal (e.g. no MX record observed) does not prove nonexistence. It may not have been evaluated.',
@@ -28,6 +29,11 @@ const LIMITATIONS_TEXT = [
28
29
  'Brand Profile references record an explicit analyst-selected association only; they do not establish ownership, attribution, intent, safety, or maliciousness.',
29
30
  'Provider workflow outcomes and independently observed technical effects remain separately attributed. A provider outcome does not establish independent remediation, absence, or safety; neither evidence family establishes legal sufficiency or provider performance.',
30
31
  'Generated locally in the browser. Review the package before sharing it.',
32
+ ]);
33
+ const LIMITATIONS_TEXT = [
34
+ ...PUBLISHED_V2_3_LIMITATIONS.slice(0, 2),
35
+ 'Snapshot hostnames are excluded; supporting DNS, TLS and web observations need not concern the Case registration domain.',
36
+ ...PUBLISHED_V2_3_LIMITATIONS.slice(2),
31
37
  ].join(' ');
32
38
  function escapeMarkdownInline(text) {
33
39
  return String(text)
@@ -100,6 +106,7 @@ function pickKnownSnapshotFields(snapshot) {
100
106
  return {
101
107
  id: snapshot.id,
102
108
  fingerprint: snapshot.fingerprint,
109
+ ...(snapshot.factorOrder ? { factorOrder: snapshot.factorOrder } : {}),
103
110
  firstCapturedAt: snapshot.firstCapturedAt,
104
111
  capturedAt: snapshot.capturedAt,
105
112
  source: snapshot.source,
@@ -165,53 +172,17 @@ export function buildCaseReport(caseRecord, options = {}) {
165
172
  const now = generatedAt || new Date().toISOString();
166
173
  const generator = buildPortableGeneratorMetadata(options.applicationVersion);
167
174
  // --- Build JSON report ---
168
- const timelineEntries = [];
169
- if (Array.isArray(caseRecord.evidenceHistory) && caseRecord.evidenceHistory.length > 0) {
170
- const chronological = [...caseRecord.evidenceHistory];
171
- for (let i = 0; i < chronological.length; i++) {
172
- const snapshot = chronological[i];
173
- if (!snapshot)
174
- continue;
175
- const isBaseline = i === 0;
176
- const hasRepeatedObservation = snapshot.firstCapturedAt !== snapshot.capturedAt;
177
- let changes = null;
178
- let hasIncomparableChange = false;
179
- let incomparableReasons = [];
180
- if (!isBaseline) {
181
- const previous = chronological[i - 1];
182
- if (!previous)
183
- continue;
184
- const rawChanges = compareCaseEvidence(previous, snapshot);
185
- const allIncomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
186
- const hostnameContextChanged = allIncomparableReasons.includes('observation-context');
187
- incomparableReasons = allIncomparableReasons.filter((reason) => reason !== 'observation-context');
188
- if (rawChanges.length > 0) {
189
- changes = rawChanges.map((change) => ({
190
- field: change.field,
191
- label: change.label,
192
- before: change.before,
193
- after: change.after,
194
- tone: change.tone,
195
- }));
196
- }
197
- else if (snapshot.fingerprint !== previous.fingerprint
198
- && incomparableReasons.length === 0
199
- && !hostnameContextChanged) {
200
- incomparableReasons = ['other'];
201
- }
202
- hasIncomparableChange = incomparableReasons.length > 0;
203
- }
204
- timelineEntries.push({
205
- snapshot: pickKnownSnapshotFields(snapshot),
206
- isBaseline,
207
- hasRepeatedObservation,
208
- changes,
209
- hasIncomparableChange,
210
- incomparableReasons,
211
- });
212
- }
213
- }
214
- const latest = latestCaseEvidence({ evidenceHistory: caseRecord.evidenceHistory ?? undefined });
175
+ const timeline = caseEvidenceTimeline(caseRecord.evidenceHistory);
176
+ const timelineEntries = timeline.map((entry) => ({
177
+ snapshot: pickKnownSnapshotFields(entry.snapshot),
178
+ isBaseline: entry.isBaseline,
179
+ hasRepeatedObservation: entry.hasRepeatedObservation,
180
+ changes: entry.changes?.map(({ field, label, before, after, tone }) => ({ field, label, before, after, tone })) ?? null,
181
+ hasIncomparableChange: entry.hasIncomparableChange,
182
+ incomparableReasons: [...entry.incomparableReasons],
183
+ }));
184
+ const selection = currentCaseEvidence(caseRecord);
185
+ const latest = selection.snapshot;
215
186
  const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
216
187
  const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);
217
188
  const observedEffects = caseRecord.observedEffects ?? {
@@ -233,6 +204,7 @@ export function buildCaseReport(caseRecord, options = {}) {
233
204
  application: generator,
234
205
  case: {
235
206
  id: caseRecord.id,
207
+ title: caseRecord.title ?? '',
236
208
  domain: caseRecord.domain,
237
209
  status: caseRecord.status,
238
210
  disposition: caseRecord.disposition,
@@ -292,12 +264,28 @@ export function buildCaseReport(caseRecord, options = {}) {
292
264
  })),
293
265
  },
294
266
  responseLifecycle,
295
- limitations: LIMITATIONS_TEXT,
267
+ limitations: [LIMITATIONS_TEXT, selection.limitation, ...new Set(timeline.map((entry) => entry.orderingLimitation))].filter(Boolean).join(' '),
296
268
  };
297
269
  // --- Build Markdown ---
298
270
  const md = buildMarkdown(json, options.includeAttribution !== false);
299
271
  return { json, markdown: md };
300
272
  }
273
+ /** Expected strict reader projection; historical disclosure wording is immutable. */
274
+ export function buildCaseReportVerificationProjection(caseRecord, options, schemaVersion) {
275
+ const current = buildCaseReport(caseRecord, options).json;
276
+ if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
277
+ return current;
278
+ if (schemaVersion !== PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION) {
279
+ throw new TypeError('No strict Case report projection is defined for this version.');
280
+ }
281
+ const { title: _title, ...publishedCase } = current.case;
282
+ return {
283
+ ...current,
284
+ case: publishedCase,
285
+ schemaVersion,
286
+ limitations: PUBLISHED_V2_3_LIMITATIONS.join(' ') + current.limitations.slice(LIMITATIONS_TEXT.length),
287
+ };
288
+ }
301
289
  // ---------------------------------------------------------------------------
302
290
  // Markdown generation
303
291
  // ---------------------------------------------------------------------------
@@ -312,6 +300,11 @@ function buildMarkdown(report, includeAttribution) {
312
300
  const domain = escapeMarkdownInline(report.case.domain || 'unknown');
313
301
  lines.push(`# Case Report: ${domain}`);
314
302
  lines.push('');
303
+ if (report.case.title) {
304
+ lines.push(`**Incident:** ${escapeMarkdownInline(report.case.title)}`);
305
+ lines.push('');
306
+ }
307
+ lines.push(`**Case ID:** ${escapeMarkdownInline(report.case.id)}`);
315
308
  // Metadata
316
309
  lines.push(`**Generated:** ${escapeMarkdownInline(report.generatedAt)}`);
317
310
  lines.push(`**Report schema:** ${escapeMarkdownInline(report.schema)} v${report.schemaVersion}`);
@@ -357,7 +350,9 @@ function buildMarkdown(report, includeAttribution) {
357
350
  lines.push(`- **Source:** ${escapeMarkdownInline(formatReportValue(a.source))}`);
358
351
  }
359
352
  else {
360
- lines.push('No evidence captured.');
353
+ lines.push(report.evidenceTimeline.length
354
+ ? 'Retained snapshots have no unique latest assessment. Review the timeline and limitations.'
355
+ : 'No evidence captured.');
361
356
  }
362
357
  lines.push('');
363
358
  // Evidence timeline
@@ -452,6 +447,8 @@ function buildMarkdown(report, includeAttribution) {
452
447
  }
453
448
  if (entry.hasIncomparableChange) {
454
449
  const reasons = Array.isArray(entry.incomparableReasons) ? entry.incomparableReasons : [];
450
+ if (reasons.includes('observation-context'))
451
+ lines.push('> Observation targets differ or are unknown. Field-level additions, removals and beneficial changes are withheld because their scopes cannot be compared reliably. Exact hostnames remain excluded from this report.');
455
452
  if (reasons.includes('risk-model'))
456
453
  lines.push('> Risk scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
457
454
  if (reasons.includes('opportunity-model'))
@@ -478,7 +475,7 @@ function buildMarkdown(report, includeAttribution) {
478
475
  lines.push('No evidence pins recorded.');
479
476
  for (const pin of response.evidencePins) {
480
477
  lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
481
- lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt)}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
478
+ lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt ?? 'Time unavailable')}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
482
479
  if (pin.limitations.length)
483
480
  lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
484
481
  }
@@ -488,7 +485,7 @@ function buildMarkdown(report, includeAttribution) {
488
485
  if (!response.sightings.length)
489
486
  lines.push('No source-qualified sightings recorded.');
490
487
  for (const sighting of response.sightings) {
491
- lines.push(`- **${escapeMarkdownInline(sighting.state.replaceAll('_', ' '))}:** ${escapeMarkdownInline(sighting.category)} (${escapeMarkdownInline(sighting.observedAt)})`);
488
+ lines.push(`- **${escapeMarkdownInline(sighting.state.replaceAll('_', ' '))}:** ${escapeMarkdownInline(sighting.category)} (${escapeMarkdownInline(sighting.observedAt ?? 'Observation time unavailable')})`);
492
489
  lines.push(` Source: ${escapeMarkdownInline(sighting.source)}; class ${escapeMarkdownInline(sighting.sourceClass)}; completeness ${escapeMarkdownInline(sighting.completeness)}.`);
493
490
  if (sighting.evidencePinId)
494
491
  lines.push(` Evidence pin: ${escapeMarkdownInline(sighting.evidencePinId)}`);
@@ -512,6 +509,11 @@ function buildMarkdown(report, includeAttribution) {
512
509
  lines.push(`- **${escapeMarkdownInline(assertion.state)}:** ${escapeMarkdownInline(assertion.statement)}`);
513
510
  if (assertion.rationale)
514
511
  lines.push(` ${escapeMarkdownInline(assertion.rationale)}`);
512
+ if (assertion.recheck) {
513
+ lines.push(` Recheck target: ${escapeMarkdownInline(assertion.recheck.targetHostname)}; comparison conditions: ${escapeMarkdownInline(assertion.recheck.conditions)}`);
514
+ if (assertion.recheck.baselinePinId)
515
+ lines.push(` Baseline evidence pin: ${escapeMarkdownInline(assertion.recheck.baselinePinId)}`);
516
+ }
515
517
  if (assertion.provenance) {
516
518
  lines.push(` External provenance: ${escapeMarkdownInline(assertion.provenance.format.toUpperCase())}; source ${escapeMarkdownInline(assertion.provenance.sourceName)}; file SHA-256 ${escapeMarkdownInline(assertion.provenance.sourceDigestSha256)}.`);
517
519
  if (assertion.provenance.publisher)
@@ -544,6 +546,7 @@ function buildMarkdown(report, includeAttribution) {
544
546
  for (const decision of response.decisions) {
545
547
  lines.push(`- **${escapeMarkdownInline(decision.summary)}** (${escapeMarkdownInline(decision.createdAt)})`);
546
548
  lines.push(` ${escapeMarkdownInline(decision.rationale)}`);
549
+ lines.push(` Analyst confidence: ${escapeMarkdownInline(decision.confidence)}${decision.confidenceBasis ? ` — ${escapeMarkdownInline(decision.confidenceBasis)}` : ''}.`);
547
550
  if (decision.evidencePinIds.length)
548
551
  lines.push(` Evidence pins: ${escapeMarkdownInline(decision.evidencePinIds.join(', '))}`);
549
552
  }
@@ -555,6 +558,10 @@ function buildMarkdown(report, includeAttribution) {
555
558
  for (const action of response.actions) {
556
559
  lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
557
560
  lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
561
+ if (action.routeObservedAt)
562
+ lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
563
+ if (action.routeReviewAfter)
564
+ lines.push(` Route review due: ${escapeMarkdownInline(action.routeReviewAfter)}`);
558
565
  if (action.originActionId)
559
566
  lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
560
567
  if (action.dueAt)
@@ -618,10 +625,19 @@ function buildMarkdown(report, includeAttribution) {
618
625
  : `- Latest independently observed change time: Withheld because the independent change state is ${escapeMarkdownInline(report.responseLifecycle.observedChangeState)}.`);
619
626
  if (observedEffect)
620
627
  lines.push(`- Latest independent review: ${escapeMarkdownInline(observedEffect.state.replaceAll('_', ' '))} at ${escapeMarkdownInline(observedEffect.observedAt)} from ${escapeMarkdownInline(observedEffect.source)}.`);
628
+ else if (response.observedEffects.reviews.length)
629
+ lines.push('- A single latest independent review cannot be selected from the retained observation times. Review the individual records below.');
621
630
  if (!response.observedEffects.reviews.length)
622
631
  lines.push('- No independent observed-effect review recorded.');
623
632
  for (const review of response.observedEffects.reviews) {
624
633
  lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
634
+ if (review.recheck) {
635
+ lines.push(` Question ${escapeMarkdownInline(review.recheck.questionId)}: ${escapeMarkdownInline(review.recheck.question)}`);
636
+ lines.push(` Recheck target: ${escapeMarkdownInline(review.recheck.targetHostname)}; retained comparison conditions: ${escapeMarkdownInline(review.recheck.conditions)}`);
637
+ lines.push(` Condition comparison: ${escapeMarkdownInline(CASE_RECHECK_CONDITIONS[review.recheck.conditionsMatch])}`);
638
+ if (review.recheck.baselinePinId)
639
+ lines.push(` Baseline evidence pin: ${escapeMarkdownInline(review.recheck.baselinePinId)}`);
640
+ }
625
641
  if (review.evidencePinId)
626
642
  lines.push(` Evidence pin: ${escapeMarkdownInline(review.evidencePinId)}`);
627
643
  if (review.sightingId)