@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,9 +1,10 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { normalizeHttpSummary } from "./http-summary.js";
4
- import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
5
- import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.js";
6
- import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
3
+ import { normalizeHttpSummary } from "./http-summary.mjs";
4
+ import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
5
+ import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
6
+ import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
7
+ import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
7
8
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
8
9
  if (value == null)
9
10
  return null;
@@ -81,6 +82,12 @@ function dayOf(value) {
81
82
  const iso = isoOrNull(value);
82
83
  return iso ? iso.slice(0, 10) : null;
83
84
  }
85
+ function lifecycleTimestamp(value, sourceVersion) {
86
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value)) {
87
+ return caseTimestampOrNull(`${value}T00:00:00Z`);
88
+ }
89
+ return caseTimestampOrNull(value, sourceVersion);
90
+ }
84
91
  // Capture completeness is recorded explicitly, never inferred from whether a
85
92
  // boolean happens to be false. 'fast' captures skip the DNS/site/HTML probes;
86
93
  // 'deep' evaluates them; 'unknown' is for migrated/imported evidence whose depth
@@ -104,6 +111,7 @@ const DEEP_SIGNAL_FIELDS = [
104
111
  // included so captures of differing completeness can never be confused for one
105
112
  // another. Deterministic ordering here is what makes the fingerprint stable.
106
113
  const MATERIAL_FIELD_ORDER = [
114
+ 'inputHostname',
107
115
  'scanDepth',
108
116
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
109
117
  'riskFactors', 'opportunityFactors',
@@ -114,6 +122,7 @@ const MATERIAL_FIELD_ORDER = [
114
122
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
115
123
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
116
124
  'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
125
+ 'profileContextState', 'profileContextLimitation',
117
126
  'mutationTypes',
118
127
  ];
119
128
  // The canonical, comparison-safe value of a material field. Registrar casing,
@@ -156,7 +165,7 @@ function isEmptyMaterial(value) {
156
165
  }
157
166
  // Fields that describe the capture rather than assert evidence, so they never
158
167
  // on their own keep an otherwise-empty snapshot alive.
159
- const NON_EVIDENCE_MATERIAL = new Set(['scanDepth', 'confidence']);
168
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
160
169
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
161
170
  // bare confidence/unknown-availability) is dropped rather than added to a
162
171
  // timeline.
@@ -172,8 +181,15 @@ function hasMaterialEvidence(snapshot) {
172
181
  // Deterministic string form of the material identity, keys in fixed order.
173
182
  function canonicalMaterialString(snapshot) {
174
183
  const canonical = {};
175
- for (const field of MATERIAL_FIELD_ORDER)
176
- canonical[field] = materialValue(field, snapshot);
184
+ for (const field of MATERIAL_FIELD_ORDER) {
185
+ const value = materialValue(field, snapshot);
186
+ // Preserve historical fingerprints when the new v14 observation-context
187
+ // field is absent. A retained hostname is still material and therefore
188
+ // separates otherwise-identical captures.
189
+ if (field === 'inputHostname' && value === null)
190
+ continue;
191
+ canonical[field] = value;
192
+ }
177
193
  return JSON.stringify(canonical);
178
194
  }
179
195
  /**
@@ -185,7 +201,7 @@ function canonicalMaterialString(snapshot) {
185
201
  * pass a real fallback; the import path deliberately passes an older case
186
202
  * timestamp (never "now") so malformed imported evidence can't appear newest.
187
203
  * @param {unknown} raw
188
- * @param {{ source?: string, fallback?: string | null }} [options]
204
+ * @param {{ source?: string, fallback?: string | null, sourceVersion?: number | null, caseDomain?: string | null }} [options]
189
205
  * @returns {CaseEvidenceSnapshot | null}
190
206
  */
191
207
  export function normalizeSnapshot(raw, options = {}) {
@@ -199,7 +215,12 @@ function buildSnapshot(raw, options) {
199
215
  const record = objectRecord(raw);
200
216
  const scanDepth = normalizeScanDepth(record.scanDepth);
201
217
  const httpSummary = normalizeHttpSummary(record);
218
+ const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
219
+ const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
202
220
  const fields = {
221
+ inputHostname: acceptsInputHostname
222
+ ? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
223
+ : null,
203
224
  scanDepth,
204
225
  availability: evidenceString(record.availability),
205
226
  confidence: evidenceString(record.confidence),
@@ -210,8 +231,8 @@ function buildSnapshot(raw, options) {
210
231
  riskFactors: normalizeFactors(record.riskFactors),
211
232
  opportunityFactors: normalizeFactors(record.opportunityFactors),
212
233
  registrar: evidenceString(record.registrar),
213
- createdDate: isoOrNull(record.createdDate),
214
- expiryDate: isoOrNull(record.expiryDate),
234
+ createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
235
+ expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
215
236
  nameservers: normalizeNameserverList(record.nameservers),
216
237
  hasMx: boolOrNull(record.hasMx),
217
238
  hasSpf: boolOrNull(record.hasSpf),
@@ -239,6 +260,12 @@ function buildSnapshot(raw, options) {
239
260
  idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
240
261
  pageBaselineMatch: boolOrNull(record.pageBaselineMatch),
241
262
  hasActiveBrandProfile: boolOrNull(record.hasActiveBrandProfile),
263
+ profileContextState: acceptsProfileContext && ['loading', 'ready', 'unavailable'].includes(String(record.profileContextState))
264
+ ? record.profileContextState
265
+ : null,
266
+ profileContextLimitation: acceptsProfileContext
267
+ ? evidenceString(record.profileContextLimitation, MAX_EVIDENCE_DETAIL_LENGTH)
268
+ : null,
242
269
  mutationTypes: normalizeMutationList(record.mutationTypes),
243
270
  };
244
271
  // A version without an actual risk assessment is orphaned metadata. Drop it
@@ -278,10 +305,10 @@ function buildSnapshot(raw, options) {
278
305
  }
279
306
  if (!hasMaterialEvidence(fields))
280
307
  return null;
281
- const capturedAt = isoOrNull(record.capturedAt) || options.fallback || null;
308
+ const capturedAt = caseTimestampOrNull(record.capturedAt, options.sourceVersion) || options.fallback || null;
282
309
  if (!capturedAt)
283
310
  return null; // an evidence entry with no placeable time is skipped
284
- let firstCapturedAt = isoOrNull(record.firstCapturedAt) || capturedAt;
311
+ let firstCapturedAt = caseTimestampOrNull(record.firstCapturedAt, options.sourceVersion) || capturedAt;
285
312
  if (Date.parse(firstCapturedAt) > Date.parse(capturedAt))
286
313
  firstCapturedAt = capturedAt;
287
314
  const source = typeof record.source === 'string' && EVIDENCE_SOURCE_SET.has(record.source)
@@ -343,7 +370,7 @@ function compareSnapshotChrono(a, b) {
343
370
  * snapshots are retained up to the per-case bound; ids are made unique within
344
371
  * the case.
345
372
  * @param {unknown} rawList
346
- * @param {{ source?: string, fallback?: string | null }} [options]
373
+ * @param {{ source?: string, fallback?: string | null, sourceVersion?: number | null, caseDomain?: string | null }} [options]
347
374
  * @returns {CaseEvidenceSnapshot[]}
348
375
  */
349
376
  export function normalizeEvidenceHistory(rawList, options = {}) {
@@ -453,12 +480,14 @@ function valuesMateriallyEqual(field, previous, current) {
453
480
  * in the same observation produced an ordinary material change.
454
481
  * @param {CaseEvidenceSnapshot | null | undefined} previous
455
482
  * @param {CaseEvidenceSnapshot | null | undefined} current
456
- * @returns {Array<'scan-depth' | 'risk-model'>}
483
+ * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
457
484
  */
458
485
  export function caseEvidenceIncomparableReasons(previous, current) {
459
486
  if (!previous || !current || previous.fingerprint === current.fingerprint)
460
487
  return [];
461
488
  const reasons = [];
489
+ if (previous.inputHostname !== current.inputHostname)
490
+ reasons.push('observation-context');
462
491
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
463
492
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
464
493
  if (hasRiskEvidence && !riskModelComparable(previous, current))
@@ -0,0 +1,132 @@
1
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
2
+ export const MAX_CASE_INVESTIGATION_BRANCHES = 8;
3
+ export const MAX_CASE_BRANCH_NAME_LENGTH = 80;
4
+ export const MAX_CASE_BRANCH_REFERENCES = 12;
5
+ export const CASE_INVESTIGATION_BRANCH_STATES = ['active', 'resolved'];
6
+ const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
7
+ const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
+ }
11
+ function text(value, maximum) {
12
+ return typeof value === 'string'
13
+ ? value.replace(CONTROL_REPLACE_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
14
+ : '';
15
+ }
16
+ function iso(value, fallback, options = {}) {
17
+ return normalizeExplicitIsoTimestamp(value)
18
+ ?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null)
19
+ ?? fallback;
20
+ }
21
+ function hash(value) {
22
+ let output = 2166136261 >>> 0;
23
+ for (let index = 0; index < value.length; index += 1) {
24
+ output ^= value.charCodeAt(index);
25
+ output = Math.imul(output, 16777619);
26
+ }
27
+ return (output >>> 0).toString(36);
28
+ }
29
+ function safeId(value, raw) {
30
+ return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : `branch-${hash(JSON.stringify(raw))}`;
31
+ }
32
+ function freshId() {
33
+ return typeof globalThis.crypto?.randomUUID === 'function'
34
+ ? globalThis.crypto.randomUUID()
35
+ : `branch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
36
+ }
37
+ function ids(value, valid) {
38
+ if (!Array.isArray(value))
39
+ return [];
40
+ const output = new Set();
41
+ for (const item of value.slice(0, MAX_CASE_BRANCH_REFERENCES * 2)) {
42
+ if (typeof item === 'string' && SAFE_ID_RE.test(item) && valid.has(item))
43
+ output.add(item);
44
+ if (output.size >= MAX_CASE_BRANCH_REFERENCES)
45
+ break;
46
+ }
47
+ return [...output];
48
+ }
49
+ function normalizeBranch(value, fallback, references, options = {}) {
50
+ const item = record(value);
51
+ const name = text(item.name, MAX_CASE_BRANCH_NAME_LENGTH);
52
+ if (!name)
53
+ return null;
54
+ const createdAt = iso(item.createdAt, fallback, options);
55
+ const branch = Object.freeze({
56
+ id: safeId(item.id, { name, createdAt }),
57
+ name,
58
+ state: item.state === 'resolved' ? 'resolved' : 'active',
59
+ evidencePinIds: Object.freeze(ids(item.evidencePinIds, references.evidencePinIds)),
60
+ checkpointIds: Object.freeze(ids(item.checkpointIds, references.checkpointIds)),
61
+ assertionIds: Object.freeze(ids(item.assertionIds, references.assertionIds)),
62
+ actionIds: Object.freeze(ids(item.actionIds, references.actionIds)),
63
+ createdAt,
64
+ updatedAt: iso(item.updatedAt, createdAt, options),
65
+ });
66
+ return branch.evidencePinIds.length || branch.checkpointIds.length || branch.assertionIds.length || branch.actionIds.length
67
+ ? branch
68
+ : null;
69
+ }
70
+ export function caseInvestigationBranchReferences(input) {
71
+ return Object.freeze({
72
+ evidencePinIds: new Set(input.evidencePins.map((item) => item.id)),
73
+ checkpointIds: new Set(input.evidencePins.flatMap((item) => item.checkpointId ? [item.checkpointId] : [])),
74
+ assertionIds: new Set(input.assertions.map((item) => item.id)),
75
+ actionIds: new Set(input.actions.map((item) => item.id)),
76
+ });
77
+ }
78
+ export function normalizeCaseInvestigationBranches(value, fallback, references, options = {}) {
79
+ if (!Array.isArray(value))
80
+ return [];
81
+ const byId = new Map();
82
+ for (const item of value.slice(0, MAX_CASE_INVESTIGATION_BRANCHES * 2)) {
83
+ const normalized = normalizeBranch(item, fallback, references, options);
84
+ if (!normalized)
85
+ continue;
86
+ const current = byId.get(normalized.id);
87
+ if (!current || normalized.updatedAt > current.updatedAt)
88
+ byId.set(normalized.id, normalized);
89
+ }
90
+ return [...byId.values()]
91
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
92
+ .slice(-MAX_CASE_INVESTIGATION_BRANCHES);
93
+ }
94
+ export function appendCaseInvestigationBranch(current, value, now, references) {
95
+ if (current.length >= MAX_CASE_INVESTIGATION_BRANCHES) {
96
+ throw new Error(`Each case is limited to ${MAX_CASE_INVESTIGATION_BRANCHES} investigation branches.`);
97
+ }
98
+ const item = record(value);
99
+ const branch = normalizeBranch({ ...item, id: freshId(), createdAt: now, updatedAt: now }, now, references);
100
+ if (!branch)
101
+ throw new Error('An investigation branch requires a name and at least one valid case reference.');
102
+ return normalizeCaseInvestigationBranches([...current, branch], now, references);
103
+ }
104
+ export function updateCaseInvestigationBranch(current, value, now, references) {
105
+ const patch = record(value);
106
+ const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
107
+ const index = current.findIndex((item) => item.id === id);
108
+ if (index < 0)
109
+ throw new Error('That investigation branch no longer exists.');
110
+ const existing = current[index];
111
+ if (!existing)
112
+ throw new Error('That investigation branch no longer exists.');
113
+ const normalized = normalizeBranch({
114
+ ...existing,
115
+ ...(Object.hasOwn(patch, 'name') ? { name: patch.name } : {}),
116
+ ...(Object.hasOwn(patch, 'state') ? { state: patch.state } : {}),
117
+ ...(Object.hasOwn(patch, 'evidencePinIds') ? { evidencePinIds: patch.evidencePinIds } : {}),
118
+ ...(Object.hasOwn(patch, 'checkpointIds') ? { checkpointIds: patch.checkpointIds } : {}),
119
+ ...(Object.hasOwn(patch, 'assertionIds') ? { assertionIds: patch.assertionIds } : {}),
120
+ ...(Object.hasOwn(patch, 'actionIds') ? { actionIds: patch.actionIds } : {}),
121
+ id,
122
+ updatedAt: now,
123
+ }, now, references);
124
+ if (!normalized)
125
+ throw new Error('An investigation branch requires a name and at least one valid case reference.');
126
+ const next = [...current];
127
+ next[index] = normalized;
128
+ return normalizeCaseInvestigationBranches(next, now, references);
129
+ }
130
+ export function mergeCaseInvestigationBranches(local, imported, fallback, references) {
131
+ return normalizeCaseInvestigationBranches([...local, ...imported], fallback, references);
132
+ }