@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
@@ -85,6 +85,9 @@ export function saveInvestigationTemplate(localRaw, candidateRaw) {
85
85
  if (!candidate)
86
86
  throw new Error('The investigation template is incomplete or invalid.');
87
87
  const local = normalizeInvestigationTemplateStore(localRaw).templates;
88
+ if (!local.some((item) => item.id === candidate.id) && local.length >= MAX_INVESTIGATION_TEMPLATES) {
89
+ throw new Error(`Investigation-template storage is full (${MAX_INVESTIGATION_TEMPLATES} templates). Export and remove a template before saving another; no templates were changed.`);
90
+ }
88
91
  return normalizeInvestigationTemplateStore([
89
92
  candidate,
90
93
  ...local.filter((item) => item.id !== candidate.id),
@@ -126,11 +129,23 @@ export function mergeInvestigationTemplates(localRaw, incomingRaw) {
126
129
  const byId = new Map(local.map((item) => [item.id, item]));
127
130
  let added = 0;
128
131
  let updated = 0;
132
+ let skipped = 0;
129
133
  for (const item of incoming) {
130
- if (byId.has(item.id))
134
+ const existing = byId.get(item.id);
135
+ if (existing) {
136
+ if (item.updatedAt <= existing.updatedAt) {
137
+ skipped += 1;
138
+ continue;
139
+ }
131
140
  updated += 1;
132
- else
141
+ }
142
+ else {
143
+ if (byId.size >= MAX_INVESTIGATION_TEMPLATES) {
144
+ skipped += 1;
145
+ continue;
146
+ }
133
147
  added += 1;
148
+ }
134
149
  byId.set(item.id, item);
135
150
  }
136
151
  const templates = normalizeInvestigationTemplateStore([...byId.values()]).templates;
@@ -138,7 +153,7 @@ export function mergeInvestigationTemplates(localRaw, incomingRaw) {
138
153
  templates,
139
154
  added,
140
155
  updated,
141
- skipped: Math.max(0, incoming.length - added - updated),
142
- pruned: Math.max(0, byId.size - templates.length),
156
+ skipped,
157
+ pruned: 0,
143
158
  };
144
159
  }
@@ -5,12 +5,12 @@
5
5
  import { normalizeDomain } from "../cases/case-model.mjs";
6
6
  import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
- import { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
9
- export { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
8
+ import { PAGE_FINGERPRINT_TOKEN_LIMITS } from "../contracts/page-fingerprints.mjs";
9
+ import { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
10
+ export { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
10
11
  const SHA256_RE = /^[a-f0-9]{64}$/i;
11
12
  const SIMHASH_RE = /^[a-f0-9]{16}$/i;
12
13
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
13
- const MAX_HTML_TOKENS = 4096;
14
14
  const MAX_TEXT_TOKENS = 8192;
15
15
  const MAX_FORMS = 50;
16
16
  const MAX_FORM_CONTROLS = 500;
@@ -50,12 +50,12 @@ function shaComponent(raw, countKey, maximum) {
50
50
  truncated: value.truncated === true,
51
51
  };
52
52
  }
53
- function visibleTextComponent(raw) {
53
+ function visibleTextComponent(raw, maximum = MAX_TEXT_TOKENS) {
54
54
  const value = record(raw);
55
55
  if (!value || value.algorithm !== 'simhash64-v1' || typeof value.value !== 'string' || !SIMHASH_RE.test(value.value))
56
56
  return null;
57
- const tokenCount = count(value.tokenCount, MAX_TEXT_TOKENS);
58
- const featureCount = count(value.featureCount, MAX_TEXT_TOKENS);
57
+ const tokenCount = count(value.tokenCount, maximum);
58
+ const featureCount = count(value.featureCount, maximum);
59
59
  if (tokenCount === null || featureCount === null)
60
60
  return null;
61
61
  return {
@@ -66,20 +66,21 @@ function visibleTextComponent(raw) {
66
66
  truncated: value.truncated === true,
67
67
  };
68
68
  }
69
- function domComponent(raw) {
69
+ function domComponent(raw, fingerprintVersion) {
70
70
  const value = record(raw);
71
- if (!value || value.algorithm !== 'sha256' || value.parser !== 'static-tag-sequence-v1')
71
+ if (!value || value.algorithm !== 'sha256' || value.parser !== PAGE_FINGERPRINT_PARSERS[fingerprintVersion])
72
72
  return null;
73
73
  const digest = sha256(value.value);
74
- const nodeCount = count(value.nodeCount, MAX_HTML_TOKENS);
74
+ const maximum = PAGE_FINGERPRINT_TOKEN_LIMITS[fingerprintVersion];
75
+ const nodeCount = count(value.nodeCount, maximum);
75
76
  if (!digest || nodeCount === null)
76
77
  return null;
77
- const similarity = value.similarity == null ? null : visibleTextComponent(value.similarity);
78
+ const similarity = value.similarity == null ? null : visibleTextComponent(value.similarity, maximum);
78
79
  return {
79
80
  algorithm: 'sha256',
80
81
  value: digest,
81
82
  nodeCount,
82
- parser: 'static-tag-sequence-v1',
83
+ parser: PAGE_FINGERPRINT_PARSERS[fingerprintVersion],
83
84
  truncated: value.truncated === true,
84
85
  ...(value.similarity == null ? {} : { similarity }),
85
86
  };
@@ -175,11 +176,13 @@ export function normalizePageBaseline(raw) {
175
176
  const lookupDomain = normalizeDomain(value.lookupDomain);
176
177
  const observedAt = timestamp(value.observedAt);
177
178
  const pageIdentityVersion = count(value.pageIdentityVersion, PAGE_IDENTITY_VERSION);
178
- const fingerprintVersion = count(value.fingerprintVersion, PAGE_FINGERPRINT_VERSION);
179
- const normalizedHtml = shaComponent(value.normalizedHtml, 'tokenCount', MAX_HTML_TOKENS);
180
- const domStructure = domComponent(value.domStructure);
179
+ if (typeof value.fingerprintVersion !== 'number' || !Object.hasOwn(PAGE_FINGERPRINT_PARSERS, value.fingerprintVersion))
180
+ return null;
181
+ const fingerprintVersion = value.fingerprintVersion;
182
+ const normalizedHtml = shaComponent(value.normalizedHtml, 'tokenCount', PAGE_FINGERPRINT_TOKEN_LIMITS[fingerprintVersion]);
183
+ const domStructure = domComponent(value.domStructure, fingerprintVersion);
181
184
  if (!domain || !lookupDomain || !observedAt || pageIdentityVersion !== PAGE_IDENTITY_VERSION
182
- || fingerprintVersion !== PAGE_FINGERPRINT_VERSION || !normalizedHtml || !domStructure)
185
+ || !normalizedHtml || !domStructure)
183
186
  return null;
184
187
  const visibleText = value.visibleText == null ? null : visibleTextComponent(value.visibleText);
185
188
  const formStructure = value.formStructure == null ? null : formComponent(value.formStructure);
@@ -1,16 +1,11 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
4
+ import { RELATIONSHIP_TYPES, qualifyRelationshipSources } from "../comparison/relationship-provenance.mjs";
3
5
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
6
  import { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
7
  export { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
6
- export const RELATIONSHIP_OBSERVATION_TYPES = Object.freeze([
7
- 'nameserver_set',
8
- 'ip_address',
9
- 'certificate',
10
- 'tracking_identifier',
11
- 'favicon',
12
- 'official_asset',
13
- ]);
8
+ export const RELATIONSHIP_OBSERVATION_TYPES = RELATIONSHIP_TYPES;
14
9
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
15
10
  const SAFE_ID_RE = /^relationship-[a-z0-9]{1,16}-[a-z0-9]{1,16}$/u;
16
11
  const SHA256_RE = /^[a-f0-9]{64}$/iu;
@@ -205,7 +200,7 @@ export function relationshipObservationId(raw) {
205
200
  }
206
201
  export function relationshipObservationStoreVersion(raw) {
207
202
  const value = record(raw);
208
- return positiveInteger(value?.version);
203
+ return typeof value?.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0 ? value.version : null;
209
204
  }
210
205
  function observationList(raw) {
211
206
  if (Array.isArray(raw))
@@ -213,7 +208,7 @@ function observationList(raw) {
213
208
  const value = record(raw);
214
209
  return Array.isArray(value?.observations) ? value.observations : [];
215
210
  }
216
- export function normalizeRelationshipObservation(raw) {
211
+ export function normalizeRelationshipObservation(raw, sourceStoreVersion = RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
217
212
  const value = record(raw);
218
213
  if (!value || typeof value.type !== 'string' || !TYPES.has(value.type))
219
214
  return null;
@@ -230,8 +225,16 @@ export function normalizeRelationshipObservation(raw) {
230
225
  const id = observationId(canonical);
231
226
  const observedAt = timestamp(value.observedAt);
232
227
  const retainedAt = timestamp(value.retainedAt);
233
- if (!observedAt || !retainedAt)
228
+ if (!retainedAt || (value.observedAt !== null && !observedAt))
234
229
  return null;
230
+ const sourceVersion = positiveInteger(value.sourceVersion) || 1;
231
+ if (value.sourceVersion !== undefined && !positiveInteger(value.sourceVersion))
232
+ throw new TypeError('This retained relationship uses an unsupported source-evidence version; no evidence was interpreted.');
233
+ if (sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
234
+ throw new TypeError('This retained relationship uses a newer source-evidence version; no evidence was interpreted.');
235
+ if (sourceStoreVersion === 1 && sourceVersion > 2)
236
+ throw new TypeError('Relationship schema 1 cannot contain newer source evidence; no evidence was interpreted.');
237
+ const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? value.sourceEvidence : [], domains, { truncated: value.truncated === true, type });
235
238
  return {
236
239
  id,
237
240
  type,
@@ -243,11 +246,14 @@ export function normalizeRelationshipObservation(raw) {
243
246
  description: metadata.description,
244
247
  classification: 'derived',
245
248
  source: 'bulk_relationship_analysis',
246
- sourceVersion: positiveInteger(value.sourceVersion) || 1,
247
- observedAt,
249
+ sourceVersion,
250
+ // Legacy scan timestamps remain available, but cannot qualify missing
251
+ // contributing-source timestamps or turn the old complete flag into proof.
252
+ observedAt: qualified.observedAt ?? (observedAt || null),
253
+ sourceEvidence: qualified.sourceEvidence,
248
254
  retainedAt,
249
- complete: value.complete === true,
250
- truncated: value.truncated === true
255
+ complete: value.complete === true && qualified.complete,
256
+ truncated: qualified.truncated
251
257
  || (Array.isArray(value.domains) && value.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
252
258
  limitations: observationLimitations(value.limitations),
253
259
  };
@@ -255,9 +261,13 @@ export function normalizeRelationshipObservation(raw) {
255
261
  export function normalizeRelationshipObservationStore(raw) {
256
262
  assertWorkspaceInputGraph(raw, 'Relationship-observation store');
257
263
  assertWorkspaceDeclaredVersion(raw, 'Relationship-observation store');
264
+ const sourceStoreVersion = relationshipObservationStoreVersion(raw) ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION;
265
+ if (sourceStoreVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
266
+ throw new TypeError(`This relationship-observation store uses newer schema ${sourceStoreVersion}; no evidence was interpreted.`);
267
+ }
258
268
  const byId = new Map();
259
269
  for (const candidate of observationList(raw).slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
260
- const observation = normalizeRelationshipObservation(candidate);
270
+ const observation = normalizeRelationshipObservation(candidate, sourceStoreVersion);
261
271
  if (!observation)
262
272
  continue;
263
273
  const existing = byId.get(observation.id);
@@ -283,7 +293,15 @@ export function createRelationshipObservation(raw, options = {}) {
283
293
  throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
284
294
  }
285
295
  const now = new Date().toISOString();
286
- const observedAt = timestamp(options.observedAt) || now;
296
+ const sourceVersion = Object.hasOwn(options, 'sourceVersion') ? positiveInteger(options.sourceVersion) : RELATIONSHIP_EVIDENCE_VERSION;
297
+ if (!sourceVersion || sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
298
+ throw new TypeError('This relationship needs a supported source-evidence version.');
299
+ const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? raw.sourceEvidence : [], domains, { truncated: options.truncated === true, type });
300
+ // Older callers can supply their original scan timestamp. It does not
301
+ // qualify the missing source metadata, and an explicit current projection
302
+ // never substitutes that timestamp for unknown contributing-source times.
303
+ const observedAt = qualified.observedAt
304
+ ?? (!Object.hasOwn(raw, 'sourceEvidence') ? timestamp(options.observedAt) || null : null);
287
305
  const retainedAt = timestamp(options.retainedAt) || now;
288
306
  const canonical = canonicalIdentity(type, normalizedValue, domains);
289
307
  const metadata = TYPE_METADATA[type];
@@ -298,11 +316,12 @@ export function createRelationshipObservation(raw, options = {}) {
298
316
  description: metadata.description,
299
317
  classification: 'derived',
300
318
  source: 'bulk_relationship_analysis',
301
- sourceVersion: positiveInteger(options.sourceVersion) || 1,
319
+ sourceVersion,
302
320
  observedAt,
321
+ sourceEvidence: qualified.sourceEvidence,
303
322
  retainedAt,
304
- complete: options.complete === true,
305
- truncated: options.truncated === true
323
+ complete: options.complete !== false && qualified.complete,
324
+ truncated: qualified.truncated
306
325
  || (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
307
326
  limitations: observationLimitations(options.limitations),
308
327
  };
@@ -356,12 +375,11 @@ export function deleteRelationshipObservation(rawStore, id) {
356
375
  export function mergeRelationshipObservations(localRaw, importedRaw) {
357
376
  assertWorkspaceInputGraph(localRaw, 'Local relationship-observation store');
358
377
  assertWorkspaceInputGraph(importedRaw, 'Imported relationship-observation document');
359
- assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
378
+ const importedVersion = assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
360
379
  const importedRecord = record(importedRaw);
361
380
  if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
362
381
  throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
363
382
  }
364
- const importedVersion = relationshipObservationStoreVersion(importedRaw);
365
383
  if (importedVersion !== null && importedVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
366
384
  throw new Error(`This relationship-observation section uses newer schema ${importedVersion}. Update the app before importing it.`);
367
385
  }
@@ -372,7 +390,7 @@ export function mergeRelationshipObservations(localRaw, importedRaw) {
372
390
  let updated = 0;
373
391
  let skipped = Math.max(0, importedInput.length - MAX_RELATIONSHIP_OBSERVATION_INPUTS);
374
392
  for (const candidate of importedInput.slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
375
- const observation = normalizeRelationshipObservation(candidate);
393
+ const observation = normalizeRelationshipObservation(candidate, importedVersion ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION);
376
394
  if (!observation) {
377
395
  skipped += 1;
378
396
  continue;
@@ -6,6 +6,7 @@ import { explainRiskScore, normalizeRiskModelVersion } from "../../lib/risk-scor
6
6
  import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
7
7
  import { normalizeDomain } from "../cases/case-model.mjs";
8
8
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
9
+ import { registryDateIso } from "../evidence/registry-dates.mjs";
9
10
  import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
10
11
  export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
11
12
  const MAX_TEXT_LENGTH = 300;
@@ -155,8 +156,7 @@ function boundedInteger(value, maximum, fallback = 0) {
155
156
  function normalizeDateValue(value) {
156
157
  if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
157
158
  return null;
158
- const parsed = Date.parse(value);
159
- return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
159
+ return registryDateIso(value)?.slice(0, 10) ?? null;
160
160
  }
161
161
  function inferredScanDepth(record) {
162
162
  if (record.scanDepth === 'deep' || record.scanDepth === 'fast')
@@ -109,15 +109,21 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
109
109
  skipped++;
110
110
  continue;
111
111
  }
112
- if (Object.prototype.hasOwnProperty.call(local, name))
112
+ const normalized = normalizeWatchlistEntry(entry);
113
+ if (Object.prototype.hasOwnProperty.call(local, name)) {
114
+ if (normalized.updatedAt <= local[name].updatedAt) {
115
+ skipped++;
116
+ continue;
117
+ }
113
118
  updated++;
119
+ }
114
120
  else if (Object.keys(local).length >= MAX_WATCHLISTS) {
115
121
  skipped++;
116
122
  continue;
117
123
  }
118
124
  else
119
125
  added++;
120
- defineEntry(local, name, normalizeWatchlistEntry(entry));
126
+ defineEntry(local, name, normalized);
121
127
  }
122
128
  return { watchlists: local, added, updated, skipped };
123
129
  }
@@ -1,7 +1,8 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { validWebObservationMode } from "../evidence/lookup-target.mjs";
3
4
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
- import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
+ import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, PAGE_FINGERPRINT_PARSERS, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
6
  export { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
6
7
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
7
8
  const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
@@ -10,6 +11,10 @@ const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
10
11
  const SNAPSHOT_TECHNOLOGY_ROLES = new Set([
11
12
  'observed_edge', 'application_platform', 'framework_runtime', 'embedded_dependency',
12
13
  ]);
14
+ export const WEBSITE_SNAPSHOT_COLLECTION_LIMITS = Object.freeze({
15
+ technologies: 40, posture: 40, sources: 16, dependencies: 20,
16
+ resourceHosts: 30, trackingIdentifiers: 30, formActionOrigins: 20,
17
+ });
13
18
  function record(value) {
14
19
  return ordinaryWorkspaceRecord(value, 'Website-snapshot input');
15
20
  }
@@ -67,18 +72,26 @@ function profileVersion(value) {
67
72
  ? Number(value)
68
73
  : null;
69
74
  }
70
- function profileProvenance(value, legacy = false) {
75
+ function profileProvenance(value, sourceVersion) {
76
+ const legacy = sourceVersion === 4;
71
77
  if (legacy) {
72
78
  return {
73
79
  technology: { version: null, state: 'legacy_unknown' },
74
80
  securityPosture: { version: null, state: 'legacy_unknown' },
81
+ pageFingerprint: { version: 1, state: 'known' },
75
82
  };
76
83
  }
77
84
  const item = record(value);
78
85
  const technologyItem = record(item?.technology);
79
86
  const securityPostureItem = record(item?.securityPosture);
87
+ const pageFingerprintItem = record(item?.pageFingerprint);
80
88
  const technologyVersion = profileVersion(technologyItem?.version);
81
89
  const securityPostureVersion = profileVersion(securityPostureItem?.version);
90
+ // Exact published snapshot writers 4 and 5 used fingerprint algorithm 1.
91
+ // Unversioned records do not receive invented historical provenance.
92
+ const candidateFingerprintVersion = sourceVersion === 5 ? 1 : profileVersion(pageFingerprintItem?.version);
93
+ const pageFingerprintVersion = candidateFingerprintVersion !== null && Object.hasOwn(PAGE_FINGERPRINT_PARSERS, candidateFingerprintVersion)
94
+ ? candidateFingerprintVersion : null;
82
95
  return {
83
96
  technology: {
84
97
  version: technologyVersion,
@@ -88,6 +101,7 @@ function profileProvenance(value, legacy = false) {
88
101
  version: securityPostureVersion,
89
102
  state: securityPostureVersion === null ? 'legacy_unknown' : 'known',
90
103
  },
104
+ pageFingerprint: { version: pageFingerprintVersion, state: pageFingerprintVersion === null ? 'legacy_unknown' : 'known' },
91
105
  };
92
106
  }
93
107
  function posture(value) {
@@ -183,8 +197,8 @@ function origin(value) {
183
197
  }
184
198
  function identityValues(value) {
185
199
  const item = record(value);
186
- const resourceHosts = values(item?.resourceHosts, 30, (candidate) => normalizeDomain(candidate));
187
- const trackingIdentifiers = values(item?.trackingIdentifiers, 30, (candidate) => {
200
+ const resourceHosts = values(item?.resourceHosts, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.resourceHosts, (candidate) => normalizeDomain(candidate));
201
+ const trackingIdentifiers = values(item?.trackingIdentifiers, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.trackingIdentifiers, (candidate) => {
188
202
  const tracker = record(candidate);
189
203
  const type = text(tracker?.type, 40).toLowerCase();
190
204
  const trackerValue = text(tracker?.value, 64).toUpperCase();
@@ -192,7 +206,7 @@ function identityValues(value) {
192
206
  ? { type, value: trackerValue }
193
207
  : null;
194
208
  });
195
- const formActionOrigins = values(item?.formActionOrigins, 20, origin);
209
+ const formActionOrigins = values(item?.formActionOrigins, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.formActionOrigins, origin);
196
210
  return { resourceHosts, trackingIdentifiers, formActionOrigins };
197
211
  }
198
212
  export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
@@ -203,20 +217,24 @@ export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
203
217
  const id = text(value?.id, 128);
204
218
  if (!domain || !observedAt || !savedAt || !id)
205
219
  return null;
220
+ const acceptsWebScope = sourceVersion === undefined || sourceVersion >= 6;
221
+ if (acceptsWebScope && !validWebObservationMode(value?.webObservationMode))
222
+ return null;
206
223
  return {
207
224
  id,
208
225
  domain,
226
+ ...(acceptsWebScope && value?.webObservationMode === 'selected_url' ? { webObservationMode: value.webObservationMode } : {}),
209
227
  observedAt,
210
228
  savedAt,
211
229
  complete: value?.complete === true,
212
230
  truncated: value?.truncated === true,
213
- profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion === 4),
214
- technologies: values(value?.technologies, 40, technology),
215
- posture: values(value?.posture, 40, posture),
231
+ profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion),
232
+ technologies: values(value?.technologies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.technologies, technology),
233
+ posture: values(value?.posture, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.posture, posture),
216
234
  identity: identity(value?.identity),
217
235
  identityValues: identityValues(value?.identityValues),
218
- sources: values(value?.sources, 16, source),
219
- dependencies: values(value?.dependencies, 20, dependency),
236
+ sources: values(value?.sources, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.sources, source),
237
+ dependencies: values(value?.dependencies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.dependencies, dependency),
220
238
  certificate: certificate(value?.certificate),
221
239
  };
222
240
  }
@@ -296,14 +314,96 @@ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
296
314
  const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
297
315
  return { snapshots, added, updated, skipped: Math.max(0, incoming.length - added - updated) };
298
316
  }
299
- function compareMap(field, before, after) {
317
+ const MAX_RECONCILED_WEBSITE_SOURCES = 16;
318
+ const UNAVAILABLE_WEBSITE_SOURCE_STATES = new Set([
319
+ 'blocked', 'error', 'not_found', 'rate_limited', 'unavailable', 'unsupported',
320
+ ]);
321
+ export function canonicalWebsiteSourceState(value) {
322
+ return value.trim().toLowerCase().replace(/[\s-]+/gu, '_');
323
+ }
324
+ function websiteSourceStateSeverity(value) {
325
+ if (UNAVAILABLE_WEBSITE_SOURCE_STATES.has(value))
326
+ return 2;
327
+ return ['complete', 'success'].includes(value) ? 0 : 1;
328
+ }
329
+ export function reconciledWebsiteSources(snapshot) {
330
+ const selected = new Map();
331
+ for (const sourceEntry of snapshot.sources.slice(0, MAX_RECONCILED_WEBSITE_SOURCES)) {
332
+ const sourceId = sourceEntry.source.trim().toLowerCase();
333
+ if (!sourceId)
334
+ continue;
335
+ const state = canonicalWebsiteSourceState(sourceEntry.state);
336
+ const severity = websiteSourceStateSeverity(state);
337
+ const current = selected.get(sourceId);
338
+ if (!current || severity > current.severity || (severity === current.severity && state.localeCompare(current.state) < 0)) {
339
+ selected.set(sourceId, { state, severity });
340
+ }
341
+ }
342
+ return [...selected.entries()]
343
+ .sort(([left], [right]) => left.localeCompare(right))
344
+ .map(([sourceName, value]) => ({ source: sourceName, state: value.state }));
345
+ }
346
+ export function websiteWithReconciledSources(snapshot) {
347
+ return { ...snapshot, sources: reconciledWebsiteSources(snapshot) };
348
+ }
349
+ export function reconciledWebsiteSourceState(snapshot, sourceId) {
350
+ const canonicalId = sourceId.trim().toLowerCase();
351
+ return reconciledWebsiteSources(snapshot)
352
+ .find((sourceEntry) => sourceEntry.source === canonicalId)?.state ?? null;
353
+ }
354
+ function completeWebsiteSourceState(value) {
355
+ const state = canonicalWebsiteSourceState(value);
356
+ return state === 'complete' || state === 'success';
357
+ }
358
+ export function websiteSnapshotFieldSource(field) {
359
+ if (field.startsWith('certificate.'))
360
+ return 'tls';
361
+ if (field.startsWith('source.'))
362
+ return field.slice('source.'.length);
363
+ if (field.startsWith('dependency.')) {
364
+ const recordType = field.slice('dependency.'.length).split(':')[0];
365
+ return recordType === 'HTTP' ? 'http' : 'dns';
366
+ }
367
+ if (['technology', 'posture', 'identity'].some((family) => field.startsWith(family)))
368
+ return 'http';
369
+ return '';
370
+ }
371
+ export function websiteSnapshotFieldComplete(snapshot, field) {
372
+ const sourceName = websiteSnapshotFieldSource(field);
373
+ const retainedSource = sourceName ? reconciledWebsiteSourceState(snapshot, sourceName) : null;
374
+ const retainedSourceComplete = !retainedSource || completeWebsiteSourceState(retainedSource);
375
+ if (field.startsWith('certificate.')) {
376
+ return Boolean(snapshot.certificate?.complete && !snapshot.certificate.truncated && retainedSourceComplete);
377
+ }
378
+ return snapshot.complete && !snapshot.truncated && retainedSourceComplete;
379
+ }
380
+ export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
381
+ return websiteSnapshotFieldComplete(snapshot, 'technology')
382
+ && websiteSnapshotFieldComplete(snapshot, 'posture')
383
+ && websiteSnapshotFieldComplete(snapshot, 'identity')
384
+ && websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
385
+ && (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
386
+ }
387
+ export function websiteSnapshotProfileComparability(before, after, profile) {
388
+ const left = before.profileProvenance[profile].version;
389
+ const right = after.profileProvenance[profile].version;
390
+ if (left === null || right === null)
391
+ return 'legacy_unknown';
392
+ return left === right ? 'comparable' : 'detector_changed';
393
+ }
394
+ function compareMap(field, before, after, options = {}) {
300
395
  const changes = [];
301
396
  for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
302
397
  const left = before.get(key) ?? null;
303
398
  const right = after.get(key) ?? null;
304
399
  if (left === right)
305
400
  continue;
306
- changes.push({ field: `${field}.${key}`, state: left === null ? 'added' : right === null ? 'removed' : 'changed', before: left, after: right });
401
+ const state = left === null
402
+ ? options.beforeComplete?.(key) === false ? 'incomparable' : 'added'
403
+ : right === null
404
+ ? options.afterComplete?.(key) === false ? 'incomparable' : 'removed'
405
+ : 'changed';
406
+ changes.push({ field: `${field}.${key}`, state, before: left, after: right });
307
407
  }
308
408
  return changes;
309
409
  }
@@ -313,35 +413,43 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
313
413
  if (!before || !after || before.domain !== after.domain) {
314
414
  return {
315
415
  compatible: false,
416
+ complete: false,
316
417
  changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }],
317
418
  dependencyTransitions: [],
318
419
  };
319
420
  }
320
421
  const technologyVersionBefore = before.profileProvenance.technology.version;
422
+ const selectedPage = before.webObservationMode === 'selected_url' || after.webObservationMode === 'selected_url';
321
423
  const technologyVersionAfter = after.profileProvenance.technology.version;
322
424
  const postureVersionBefore = before.profileProvenance.securityPosture.version;
323
425
  const postureVersionAfter = after.profileProvenance.securityPosture.version;
324
- const technologyComparability = technologyVersionBefore === null || technologyVersionAfter === null
325
- ? 'legacy_unknown'
326
- : technologyVersionBefore === technologyVersionAfter ? 'comparable' : 'detector_changed';
327
- const postureComparability = postureVersionBefore === null || postureVersionAfter === null
328
- ? 'legacy_unknown'
329
- : postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
426
+ const technologyComparability = websiteSnapshotProfileComparability(before, after, 'technology');
427
+ const postureComparability = websiteSnapshotProfileComparability(before, after, 'securityPosture');
428
+ const fingerprintComparability = websiteSnapshotProfileComparability(before, after, 'pageFingerprint');
330
429
  const changes = [
331
- ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`]))) : [{
430
+ ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), {
431
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
432
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'technology'),
433
+ }) : [{
332
434
  field: 'technology.profileVersion',
333
435
  state: technologyComparability === 'detector_changed' ? 'changed' : 'incomparable',
334
436
  before: technologyVersionBefore === null ? null : String(technologyVersionBefore),
335
437
  after: technologyVersionAfter === null ? null : String(technologyVersionAfter),
336
438
  }]),
337
- ...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))) : [{
439
+ ...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state])), {
440
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'posture'),
441
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'posture'),
442
+ }) : [{
338
443
  field: 'posture.profileVersion',
339
444
  state: postureComparability === 'detector_changed' ? 'changed' : 'incomparable',
340
445
  before: postureVersionBefore === null ? null : String(postureVersionBefore),
341
446
  after: postureVersionAfter === null ? null : String(postureVersionAfter),
342
447
  }]),
343
448
  ...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
344
- ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))),
449
+ ...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), {
450
+ beforeComplete: (key) => websiteSnapshotFieldComplete(before, `dependency.${key}`),
451
+ afterComplete: (key) => websiteSnapshotFieldComplete(after, `dependency.${key}`),
452
+ }),
345
453
  ];
346
454
  const dependencyTransitions = [];
347
455
  const beforeDependencies = new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
@@ -365,10 +473,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
365
473
  detail: 'An earlier active dependency is unresolved in the later retained observation. Resolver or collection failure remains possible.',
366
474
  });
367
475
  }
368
- else if (!left && right) {
476
+ else if (!left && right && websiteSnapshotFieldComplete(before, `dependency.${key}`)) {
369
477
  dependencyTransitions.push({ target: right.target, recordType: right.recordType, state: 'added', detail: 'This dependency first appears in the later retained snapshot.' });
370
478
  }
371
- else if (left && !right && after.complete) {
479
+ else if (left && !right && websiteSnapshotFieldComplete(after, `dependency.${key}`)) {
372
480
  dependencyTransitions.push({ target: left.target, recordType: left.recordType, state: 'removed', detail: 'This dependency is not represented in the later complete snapshot. It may have been intentionally removed or replaced.' });
373
481
  }
374
482
  if (dependencyTransitions.length >= 20)
@@ -377,11 +485,30 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
377
485
  for (const key of Object.keys(before.identity)) {
378
486
  const left = before.identity[key];
379
487
  const right = after.identity[key];
488
+ if (key !== 'faviconHash' && fingerprintComparability !== 'comparable')
489
+ continue;
380
490
  if (left === right)
381
491
  continue;
382
492
  changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
383
493
  }
384
- changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item]))), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value]))), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item]))));
494
+ if (fingerprintComparability !== 'comparable') {
495
+ changes.push({
496
+ field: 'identity.profileVersion', state: 'incomparable',
497
+ before: before.profileProvenance.pageFingerprint.version?.toString() ?? null,
498
+ after: after.profileProvenance.pageFingerprint.version?.toString() ?? null,
499
+ });
500
+ }
501
+ else
502
+ changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
503
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
504
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
505
+ }), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
506
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
507
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
508
+ }), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
509
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
510
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
511
+ }));
385
512
  if (before.certificate || after.certificate) {
386
513
  if (!before.certificate || !after.certificate) {
387
514
  changes.push({
@@ -425,11 +552,22 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
425
552
  }
426
553
  return {
427
554
  compatible: true,
555
+ complete: !selectedPage && websiteSnapshotComparisonEvidenceComplete(before)
556
+ && websiteSnapshotComparisonEvidenceComplete(after)
557
+ && technologyComparability === 'comparable'
558
+ && postureComparability === 'comparable'
559
+ && fingerprintComparability === 'comparable',
428
560
  profileComparability: {
429
561
  technology: technologyComparability,
430
562
  securityPosture: postureComparability,
563
+ pageFingerprint: fingerprintComparability,
431
564
  },
432
- changes,
433
- dependencyTransitions,
565
+ changes: selectedPage ? [
566
+ { field: 'web.collectionTarget', state: 'incomparable', before: before.webObservationMode ?? 'homepage', after: after.webObservationMode ?? 'homepage' },
567
+ ...changes.map((change) => change.field.startsWith('certificate.') || change.field.startsWith('source.') || change.field === 'completeness'
568
+ || /^dependency\.(?:CNAME|HTTPS|NS|MX)(?::|$)/u.test(change.field)
569
+ ? change : { ...change, state: 'incomparable' }),
570
+ ] : changes,
571
+ dependencyTransitions: selectedPage ? dependencyTransitions.filter((transition) => transition.recordType !== 'HTTP') : dependencyTransitions,
434
572
  };
435
573
  }