@slicedearth/whoisleuth-cli 1.47.4 → 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 (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,4 +1,4 @@
1
- // Bounded, one-connection TLS and certificate intelligence for deep scans.
1
+ // Bounded, one-connection TLS and certificate evidence for deep scans.
2
2
  //
3
3
  // The collector resolves the submitted hostname once through the same public-
4
4
  // address guard used by safe-fetch, validates every returned address again at
@@ -10,7 +10,7 @@ import * as crypto from 'node:crypto';
10
10
  import * as net from 'node:net';
11
11
  import * as tls from 'node:tls';
12
12
  import { domainToASCII } from 'node:url';
13
- import { createObservation } from "./observation.mjs";
13
+ import { createObservation } from "../packages/evidence/observation.mjs";
14
14
  import { MAX_LOOKUP_TLS_ALT_NAMES, MAX_LOOKUP_TLS_CHAIN_CERTIFICATES, MAX_LOOKUP_TLS_FINDINGS, MAX_LOOKUP_TLS_NAME_VALUES, } from "./lookup-network-evidence-bounds.mjs";
15
15
  import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
16
16
  import { parseCertificateExtensionProfile, } from "./certificate-extension-profile.mjs";
@@ -671,7 +671,7 @@ function failedTlsObservation(error, options = {}) {
671
671
  source: 'tls',
672
672
  durationMs: options.durationMs,
673
673
  complete: false,
674
- limitations: ['TLS intelligence was inconclusive; DNS, connection, timeout, policy, or handshake failure is not proof that no TLS service exists.'],
674
+ limitations: ['TLS evidence collection was inconclusive; DNS, connection, timeout, policy, or handshake failure is not proof that no TLS service exists.'],
675
675
  diagnostics: { connectionAttempts: options.connectionAttempts || 0, error: detail },
676
676
  }),
677
677
  profileVersion: TLS_PROFILE_VERSION,
@@ -692,7 +692,7 @@ function failedTlsObservation(error, options = {}) {
692
692
  findings: [],
693
693
  };
694
694
  }
695
- function skippedTlsObservation(detail = 'TLS intelligence is disabled by deployment policy.') {
695
+ function skippedTlsObservation(detail = 'TLS evidence collection is disabled by deployment policy.') {
696
696
  return {
697
697
  ...failedTlsObservation(detail, { connectionAttempts: 0 }),
698
698
  ...createObservation({
@@ -9,6 +9,7 @@ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
9
9
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
10
10
  import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
11
11
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
12
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
12
13
  const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
13
14
  const URLHAUS_MAX_RESULTS = 20;
14
15
  const URLHAUS_MAX_INPUT_RESULTS = 100;
@@ -82,8 +83,7 @@ function isoTimestamp(value) {
82
83
  const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: UTC)?$/u.test(trimmed)
83
84
  ? `${trimmed.slice(0, 10)}T${trimmed.slice(11, 19)}Z`
84
85
  : trimmed;
85
- const timestamp = Date.parse(normalized);
86
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
86
+ return normalizeExplicitIsoTimestamp(normalized);
87
87
  }
88
88
  function retryAfterSeconds(response) {
89
89
  const value = response.headers.get('retry-after');
@@ -151,6 +151,8 @@ function normalizeHostFinding(value, targetDomain) {
151
151
  || exactDomainFromUrl(value.url) !== targetDomain)
152
152
  return null;
153
153
  const observedAt = isoTimestamp(value.date_added);
154
+ if (value.date_added !== undefined && value.date_added !== null && !observedAt)
155
+ return null;
154
156
  const tags = normalizedTags(value.tags);
155
157
  tags.push(`url-status:${status}`);
156
158
  return {
@@ -7,6 +7,7 @@ import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
7
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
8
  import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
9
9
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
10
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
10
11
  const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
11
12
  const URLSCAN_SEARCH_DAYS = 90;
12
13
  const URLSCAN_MAX_RESULTS = 20;
@@ -80,10 +81,7 @@ function boundedText(value, maxLength) {
80
81
  return normalized ? normalized.slice(0, maxLength) : null;
81
82
  }
82
83
  function isoTimestamp(value) {
83
- if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
84
- return null;
85
- const timestamp = Date.parse(value);
86
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
84
+ return normalizeExplicitIsoTimestamp(value);
87
85
  }
88
86
  function retryAfterSeconds(response) {
89
87
  for (const name of ['retry-after', 'x-rate-limit-reset-after']) {
@@ -148,7 +146,10 @@ function normalizeSearchFinding(value, targetDomain) {
148
146
  if (verdicts.malicious !== true)
149
147
  return undefined;
150
148
  const categories = normalizedCategories(verdicts.categories);
151
- const observedAt = isoTimestamp(task.time) || isoTimestamp(value.date);
149
+ const rawObservedAt = task.time ?? value.date;
150
+ const observedAt = isoTimestamp(rawObservedAt);
151
+ if (rawObservedAt !== undefined && rawObservedAt !== null && !observedAt)
152
+ return null;
152
153
  const title = boundedText(page.title, 180);
153
154
  return {
154
155
  id,
@@ -2,7 +2,7 @@
2
2
  // HTTP, static page, TLS, and DNS evidence already collected by a deep Lookup.
3
3
  // Findings use fixed explanatory text and bounded counts; upstream header
4
4
  // values, certificate errors, URLs, and DNS record contents are not copied.
5
- import { createObservation } from "./observation.mjs";
5
+ import { createObservation } from "../packages/evidence/observation.mjs";
6
6
  const WEBSITE_SECURITY_POSTURE_VERSION = 2;
7
7
  const MAX_SECURITY_POSTURE_FINDINGS = 32;
8
8
  const MAX_RETAINED_ORIGINS = 30;
@@ -5,7 +5,7 @@
5
5
  // markup, header values, URL paths, and arbitrary upstream strings are never
6
6
  // retained.
7
7
  import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
8
- import { createObservation } from "./observation.mjs";
8
+ import { createObservation } from "../packages/evidence/observation.mjs";
9
9
  import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
10
  const TECHNOLOGY_PROFILE_VERSION = 10;
11
11
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
@@ -1,10 +1,21 @@
1
- // Authority-aware interpretation of WHOIS referral chains. The first
2
- // definitive non-root response decides existence; later registrar output is
3
- // diagnostic and cannot override the registry.
1
+ // Authority-aware interpretation of WHOIS referral chains. Only the first
2
+ // registry response referred by IANA can decide existence; later registrar
3
+ // output is diagnostic and cannot replace an inconclusive registry result.
4
4
  import { normalizeWhoisChain } from "./whois-normalization.mjs";
5
5
  import { parseIndentedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
6
- const NOT_FOUND_RE = /no match for|no match\b|not found|no entries found|domain not found|no object found|not registered|status\s*:\s*(?:available|free)\b|registered\s*:\s*(?:no|false)\b|is available for registration/i;
7
6
  const LINE_NOT_FOUND_PATTERNS = Object.freeze([
7
+ /^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?no match(?:[ \t]+for(?:[ \t]+domain)?(?:[ \t]+["']?[a-z0-9.-]{1,253}["']?)?)?[.!]?[ \t]*$/im,
8
+ /^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?no entries found(?:[ \t]+(?:for(?:[ \t]+(?:the[ \t]+)?(?:selected source\(s\)|this query|query[ \t]+["']?[a-z0-9.-]{1,253}["']?))|in[ \t]+the[ \t]+\.[a-z0-9-]{1,63}[ \t]+database))?[.!]?[ \t]*$/im,
9
+ /^[ \t]*[a-z0-9.-]{1,253}[ \t]*:[ \t]*no entries found\.?[ \t]*$/im,
10
+ /^[ \t]*(?:%{1,2}[ \t]*)?(?:domain[ \t]+)?not found(?::[ \t]*[a-z0-9.-]{1,253})?[.!]?[ \t]*$/im,
11
+ /^[ \t]*(?:the[ \t]+)?domain[ \t]+["']?[a-z0-9.-]{1,253}["']?[ \t]+(?:was[ \t]+)?not found\.?[ \t]*$/im,
12
+ /^[ \t]*(?:domain[ \t]+status|the queried object does not exist)[ \t]*:[ \t]*(?:domain[ \t]+not found|no object found)[.!]?[ \t]*$/im,
13
+ /^[ \t]*no object found!?[ \t]*$/im,
14
+ /^[ \t]*(?:%{1,2}[ \t]*)?(?:error(?::\d+)?[ \t:.-]*)?(?:the[ \t]+)?domain(?:[ \t]+is|[ \t]+has)?[ \t]+not[ \t]+(?:been[ \t]+)?registered\.?[ \t]*$/im,
15
+ /^[ \t]*(?:domain[ \t]+)?status[ \t]*:[ \t]*(?:available|free)[ \t]*$/im,
16
+ /^[ \t]*registration[ \t]+status[ \t]*:[ \t]*available[ \t]*$/im,
17
+ /^[ \t]*registered[ \t]*:[ \t]*(?:no|false)[ \t]*$/im,
18
+ /^[ \t]*[a-z0-9.-]{1,253}[ \t]+is[ \t]+(?:available[ \t]+for[ \t]+(?:purchase|registration)|free)[.!]?[ \t]*$/im,
8
19
  /^[ \t]*%[ \t]*nothing found[ \t]*$/im,
9
20
  /^[ \t]*[a-z0-9](?:[a-z0-9.-]{0,252})[ \t]+is free[ \t]*$/im,
10
21
  /^[ \t]*el dominio no se encuentra registrado en nic argentina[ \t]*$/im,
@@ -48,8 +59,7 @@ function classifyHopEvidence(hop, index) {
48
59
  return 'rate_limited';
49
60
  if (NZ_NOT_FOUND_RE.test(text))
50
61
  return 'negative';
51
- if (NOT_FOUND_RE.test(text)
52
- || LINE_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(text))) {
62
+ if (LINE_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(text))) {
53
63
  return 'negative';
54
64
  }
55
65
  if (index > 0
@@ -62,6 +72,26 @@ function classifyHopEvidence(hop, index) {
62
72
  }
63
73
  return 'inconclusive';
64
74
  }
75
+ function normalizedWhoisServer(value) {
76
+ if (typeof value !== 'string')
77
+ return null;
78
+ const normalized = value.trim().toLowerCase().replace(/\.+$/u, '');
79
+ return /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/u.test(normalized) ? normalized : null;
80
+ }
81
+ function ianaRegistryReferral(hop) {
82
+ if (!hop || normalizedWhoisServer(hop.server) !== 'whois.iana.org' || typeof hop.response !== 'string')
83
+ return null;
84
+ for (const pattern of [
85
+ /^[ \t]*refer:[ \t]*([a-zA-Z0-9.\-]+)/mi,
86
+ /^[ \t]*ReferralServer:[ \t]*whois:\/\/([a-zA-Z0-9.\-]+)/mi,
87
+ /^[ \t]*whois:[ \t]*([a-zA-Z0-9.\-]+)/mi,
88
+ ]) {
89
+ const referral = normalizedWhoisServer(hop.response.match(pattern)?.[1]);
90
+ if (referral)
91
+ return referral;
92
+ }
93
+ return null;
94
+ }
65
95
  export function analyzeWhoisChainAuthority(chain) {
66
96
  const source = normalizeWhoisChain(chain);
67
97
  const evidence = source.map((hop, index) => ({
@@ -70,8 +100,13 @@ export function analyzeWhoisChainAuthority(chain) {
70
100
  kind: classifyHopEvidence(hop, index),
71
101
  }));
72
102
  const failed = evidence.filter((item) => item.kind === 'error' || item.kind === 'rate_limited');
73
- const authoritative = evidence.find((item) => item.index > 0
74
- && (item.kind === 'positive' || item.kind === 'negative'));
103
+ const registryReferral = ianaRegistryReferral(source[0]);
104
+ const registryServer = normalizedWhoisServer(source[1]?.server);
105
+ const registryEvidence = registryReferral && registryServer === registryReferral ? evidence[1] : null;
106
+ const authoritative = registryEvidence
107
+ && (registryEvidence.kind === 'positive' || registryEvidence.kind === 'negative')
108
+ ? registryEvidence
109
+ : null;
75
110
  const conflict = authoritative
76
111
  ? evidence.find((item) => item.index > authoritative.index
77
112
  && (item.kind === 'positive' || item.kind === 'negative')
@@ -8,6 +8,17 @@ import { whoisQuery } from "./whois-transport.mjs";
8
8
  const IANA_WHOIS = 'whois.iana.org';
9
9
  const WHOIS_HOP_DEADLINE_MS = 12_000;
10
10
  const WHOIS_CHAIN_DEADLINE_MS = 25_000;
11
+ const MAX_WHOIS_QUERY_HOPS = 6;
12
+ function incompleteReferralHop(server, reason) {
13
+ return {
14
+ server,
15
+ queryProfile: 'not-issued',
16
+ responseEncoding: 'utf-8',
17
+ error: reason === 'hop_limit'
18
+ ? 'WHOIS referral chain reached the bounded hop limit; this referral was not queried.'
19
+ : 'WHOIS referral chain repeated a previously queried server; this referral was not queried.',
20
+ };
21
+ }
11
22
  const WHOIS_QUERY_FORMATTERS = {
12
23
  'plain-domain': (domain) => domain,
13
24
  'denic-domain-ace': (domain) => `-T dn,ace ${domain}`,
@@ -65,9 +76,11 @@ export async function buildWhoisChainUncached(query, options = {}) {
65
76
  const visited = new Set();
66
77
  let currentServer = IANA_WHOIS;
67
78
  const startedAt = now();
68
- for (let hop = 0; hop < 6; hop += 1) {
69
- if (visited.has(currentServer.toLowerCase()))
79
+ for (let hop = 0; hop < MAX_WHOIS_QUERY_HOPS; hop += 1) {
80
+ if (visited.has(currentServer.toLowerCase())) {
81
+ chain.push(incompleteReferralHop(currentServer, 'referral_loop'));
70
82
  break;
83
+ }
71
84
  visited.add(currentServer.toLowerCase());
72
85
  const transport = whoisTransportForHop(query, hop);
73
86
  const remainingMs = chainDeadlineMs - (now() - startedAt);
@@ -112,8 +125,17 @@ export async function buildWhoisChainUncached(query, options = {}) {
112
125
  response: responseText,
113
126
  });
114
127
  const referral = extractReferral(responseText);
115
- if (!referral || referral.toLowerCase() === currentServer.toLowerCase())
128
+ if (!referral)
116
129
  break;
130
+ const referralKey = referral.toLowerCase();
131
+ if (visited.has(referralKey)) {
132
+ chain.push(incompleteReferralHop(referral, 'referral_loop'));
133
+ break;
134
+ }
135
+ if (hop + 1 >= MAX_WHOIS_QUERY_HOPS) {
136
+ chain.push(incompleteReferralHop(referral, 'hop_limit'));
137
+ break;
138
+ }
117
139
  currentServer = referral;
118
140
  }
119
141
  return chain;
@@ -2,7 +2,9 @@
2
2
  // Keeping this separate lets authority analysis and field parsing share the
3
3
  // exact same defensive input boundary.
4
4
  import { MAX_WHOIS_BYTES } from "./whois-transport.mjs";
5
- const MAX_WHOIS_HOPS = 6;
5
+ // Collection performs at most six network queries and may retain one seventh,
6
+ // non-issued hop explaining a loop or hop-limit termination.
7
+ const MAX_WHOIS_HOPS = 7;
6
8
  export function normalizeWhoisChain(value) {
7
9
  if (!Array.isArray(value))
8
10
  return [];
@@ -47,9 +47,18 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
47
47
  }
48
48
  chunks.push(buffer);
49
49
  });
50
- const responseText = () => Buffer.concat(chunks, totalBytes).toString('utf8');
51
- socket.on('end', () => settle(resolve, responseText()));
52
- socket.on('close', () => settle(resolve, responseText()));
50
+ const settleResponse = () => {
51
+ try {
52
+ const responseText = new TextDecoder('utf-8', { fatal: true })
53
+ .decode(Buffer.concat(chunks, totalBytes));
54
+ settle(resolve, responseText);
55
+ }
56
+ catch {
57
+ settle(reject, new Error(`WHOIS response from ${server} was not valid UTF-8`));
58
+ }
59
+ };
60
+ socket.on('end', settleResponse);
61
+ socket.on('close', settleResponse);
53
62
  socket.on('timeout', () => {
54
63
  socket.destroy();
55
64
  settle(reject, new Error(`WHOIS request to ${server} timed out`));
@@ -175,7 +175,7 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
175
175
  };
176
176
  }
177
177
  if (type === 'CDNSKEY') {
178
- if (tokens.length !== 4 || !/^[a-z0-9+/=]{1,4096}$/iu.test(tokens[3] ?? ''))
178
+ if (tokens.length !== 4 || !/^[A-Za-z0-9+/=]{1,4096}$/u.test(tokens[3] ?? ''))
179
179
  throw new TypeError('CDNSKEY data is invalid.');
180
180
  return {
181
181
  value: `${integerToken(tokens[0], 0, 65_535, 'CDNSKEY flags')} ${integerToken(tokens[1], 0, 255, 'CDNSKEY protocol')} ${integerToken(tokens[2], 0, 255, 'CDNSKEY algorithm')} ${tokens[3]}`,
package/package.json CHANGED
@@ -33,20 +33,28 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "1.47.4",
36
+ "version": "2.0.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",
40
40
  "parse5": "8.0.1",
41
41
  "reflect-metadata": "0.2.2",
42
- "tldts": "7.4.10",
43
- "undici": "8.9.0"
42
+ "tldts": "7.4.11",
43
+ "undici": "8.10.0"
44
44
  },
45
45
  "files": [
46
46
  "bin/**/*.mjs",
47
47
  "cli/**/*.mjs",
48
48
  "lib/**/*.mjs",
49
- "frontend/src/lib/analysis/**/*.js",
49
+ "frontend/src/lib/**/*.js",
50
+ "packages/cases/**/*.mjs",
51
+ "packages/comparison/**/*.mjs",
52
+ "packages/contracts/**/*.mjs",
53
+ "packages/evidence/**/*.mjs",
54
+ "packages/interchange/**/*.mjs",
55
+ "packages/investigation/**/*.mjs",
56
+ "packages/monitoring/**/*.mjs",
57
+ "packages/workspace/**/*.mjs",
50
58
  "docs/cli.md",
51
59
  "docs/cli-reference.md",
52
60
  "DISCLOSURE",
@@ -1,5 +1,5 @@
1
- import { MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, } from "./case-record-contracts.js";
2
- import { normalizeOpaqueReferenceId } from "./opaque-reference-id.js";
1
+ import { MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, } from "./case-record-contracts.mjs";
2
+ import { normalizeOpaqueReferenceId } from "./opaque-reference-id.mjs";
3
3
  /**
4
4
  * Reads an untrusted stored or imported reference list without repairing any
5
5
  * identifier. At most 32 candidates are inspected and at most eight unique,
@@ -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',
@@ -157,7 +165,7 @@ function isEmptyMaterial(value) {
157
165
  }
158
166
  // Fields that describe the capture rather than assert evidence, so they never
159
167
  // on their own keep an otherwise-empty snapshot alive.
160
- const NON_EVIDENCE_MATERIAL = new Set(['scanDepth', 'confidence']);
168
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
161
169
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
162
170
  // bare confidence/unknown-availability) is dropped rather than added to a
163
171
  // timeline.
@@ -173,8 +181,15 @@ function hasMaterialEvidence(snapshot) {
173
181
  // Deterministic string form of the material identity, keys in fixed order.
174
182
  function canonicalMaterialString(snapshot) {
175
183
  const canonical = {};
176
- for (const field of MATERIAL_FIELD_ORDER)
177
- 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
+ }
178
193
  return JSON.stringify(canonical);
179
194
  }
180
195
  /**
@@ -186,7 +201,7 @@ function canonicalMaterialString(snapshot) {
186
201
  * pass a real fallback; the import path deliberately passes an older case
187
202
  * timestamp (never "now") so malformed imported evidence can't appear newest.
188
203
  * @param {unknown} raw
189
- * @param {{ source?: string, fallback?: string | null }} [options]
204
+ * @param {{ source?: string, fallback?: string | null, sourceVersion?: number | null, caseDomain?: string | null }} [options]
190
205
  * @returns {CaseEvidenceSnapshot | null}
191
206
  */
192
207
  export function normalizeSnapshot(raw, options = {}) {
@@ -201,7 +216,11 @@ function buildSnapshot(raw, options) {
201
216
  const scanDepth = normalizeScanDepth(record.scanDepth);
202
217
  const httpSummary = normalizeHttpSummary(record);
203
218
  const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
219
+ const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
204
220
  const fields = {
221
+ inputHostname: acceptsInputHostname
222
+ ? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
223
+ : null,
205
224
  scanDepth,
206
225
  availability: evidenceString(record.availability),
207
226
  confidence: evidenceString(record.confidence),
@@ -212,8 +231,8 @@ function buildSnapshot(raw, options) {
212
231
  riskFactors: normalizeFactors(record.riskFactors),
213
232
  opportunityFactors: normalizeFactors(record.opportunityFactors),
214
233
  registrar: evidenceString(record.registrar),
215
- createdDate: isoOrNull(record.createdDate),
216
- expiryDate: isoOrNull(record.expiryDate),
234
+ createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
235
+ expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
217
236
  nameservers: normalizeNameserverList(record.nameservers),
218
237
  hasMx: boolOrNull(record.hasMx),
219
238
  hasSpf: boolOrNull(record.hasSpf),
@@ -286,10 +305,10 @@ function buildSnapshot(raw, options) {
286
305
  }
287
306
  if (!hasMaterialEvidence(fields))
288
307
  return null;
289
- const capturedAt = isoOrNull(record.capturedAt) || options.fallback || null;
308
+ const capturedAt = caseTimestampOrNull(record.capturedAt, options.sourceVersion) || options.fallback || null;
290
309
  if (!capturedAt)
291
310
  return null; // an evidence entry with no placeable time is skipped
292
- let firstCapturedAt = isoOrNull(record.firstCapturedAt) || capturedAt;
311
+ let firstCapturedAt = caseTimestampOrNull(record.firstCapturedAt, options.sourceVersion) || capturedAt;
293
312
  if (Date.parse(firstCapturedAt) > Date.parse(capturedAt))
294
313
  firstCapturedAt = capturedAt;
295
314
  const source = typeof record.source === 'string' && EVIDENCE_SOURCE_SET.has(record.source)
@@ -351,7 +370,7 @@ function compareSnapshotChrono(a, b) {
351
370
  * snapshots are retained up to the per-case bound; ids are made unique within
352
371
  * the case.
353
372
  * @param {unknown} rawList
354
- * @param {{ source?: string, fallback?: string | null }} [options]
373
+ * @param {{ source?: string, fallback?: string | null, sourceVersion?: number | null, caseDomain?: string | null }} [options]
355
374
  * @returns {CaseEvidenceSnapshot[]}
356
375
  */
357
376
  export function normalizeEvidenceHistory(rawList, options = {}) {
@@ -461,12 +480,14 @@ function valuesMateriallyEqual(field, previous, current) {
461
480
  * in the same observation produced an ordinary material change.
462
481
  * @param {CaseEvidenceSnapshot | null | undefined} previous
463
482
  * @param {CaseEvidenceSnapshot | null | undefined} current
464
- * @returns {Array<'scan-depth' | 'risk-model'>}
483
+ * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
465
484
  */
466
485
  export function caseEvidenceIncomparableReasons(previous, current) {
467
486
  if (!previous || !current || previous.fingerprint === current.fingerprint)
468
487
  return [];
469
488
  const reasons = [];
489
+ if (previous.inputHostname !== current.inputHostname)
490
+ reasons.push('observation-context');
470
491
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
471
492
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
472
493
  if (hasRiskEvidence && !riskModelComparable(previous, current))
@@ -1,3 +1,4 @@
1
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
1
2
  export const MAX_CASE_INVESTIGATION_BRANCHES = 8;
2
3
  export const MAX_CASE_BRANCH_NAME_LENGTH = 80;
3
4
  export const MAX_CASE_BRANCH_REFERENCES = 12;
@@ -12,13 +13,10 @@ function text(value, maximum) {
12
13
  ? value.replace(CONTROL_REPLACE_RE, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
13
14
  : '';
14
15
  }
15
- function iso(value, fallback) {
16
- if (typeof value === 'string' && value.length <= 64 && !/[\u0000-\u001f\u007f]/u.test(value)) {
17
- const parsed = Date.parse(value);
18
- if (Number.isFinite(parsed))
19
- return new Date(parsed).toISOString();
20
- }
21
- return fallback;
16
+ function iso(value, fallback, options = {}) {
17
+ return normalizeExplicitIsoTimestamp(value)
18
+ ?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null)
19
+ ?? fallback;
22
20
  }
23
21
  function hash(value) {
24
22
  let output = 2166136261 >>> 0;
@@ -48,12 +46,12 @@ function ids(value, valid) {
48
46
  }
49
47
  return [...output];
50
48
  }
51
- function normalizeBranch(value, fallback, references) {
49
+ function normalizeBranch(value, fallback, references, options = {}) {
52
50
  const item = record(value);
53
51
  const name = text(item.name, MAX_CASE_BRANCH_NAME_LENGTH);
54
52
  if (!name)
55
53
  return null;
56
- const createdAt = iso(item.createdAt, fallback);
54
+ const createdAt = iso(item.createdAt, fallback, options);
57
55
  const branch = Object.freeze({
58
56
  id: safeId(item.id, { name, createdAt }),
59
57
  name,
@@ -63,7 +61,7 @@ function normalizeBranch(value, fallback, references) {
63
61
  assertionIds: Object.freeze(ids(item.assertionIds, references.assertionIds)),
64
62
  actionIds: Object.freeze(ids(item.actionIds, references.actionIds)),
65
63
  createdAt,
66
- updatedAt: iso(item.updatedAt, createdAt),
64
+ updatedAt: iso(item.updatedAt, createdAt, options),
67
65
  });
68
66
  return branch.evidencePinIds.length || branch.checkpointIds.length || branch.assertionIds.length || branch.actionIds.length
69
67
  ? branch
@@ -77,12 +75,12 @@ export function caseInvestigationBranchReferences(input) {
77
75
  actionIds: new Set(input.actions.map((item) => item.id)),
78
76
  });
79
77
  }
80
- export function normalizeCaseInvestigationBranches(value, fallback, references) {
78
+ export function normalizeCaseInvestigationBranches(value, fallback, references, options = {}) {
81
79
  if (!Array.isArray(value))
82
80
  return [];
83
81
  const byId = new Map();
84
82
  for (const item of value.slice(0, MAX_CASE_INVESTIGATION_BRANCHES * 2)) {
85
- const normalized = normalizeBranch(item, fallback, references);
83
+ const normalized = normalizeBranch(item, fallback, references, options);
86
84
  if (!normalized)
87
85
  continue;
88
86
  const current = byId.get(normalized.id);