@slicedearth/whoisleuth-cli 2.3.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -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 +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  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 +83 -61
  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-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -21,6 +21,24 @@ export const LOOKUP_CLAIM_REQUIREMENT_IDS = [
21
21
  'reviewed-case-recipient',
22
22
  'authoritative-rdap',
23
23
  ];
24
+ const CLAIM_QUESTIONS = Object.freeze({
25
+ 'registration-state': 'What does the registration authority establish?',
26
+ 'current-web-observation': 'What was the website doing at the observed time?',
27
+ 'brand-resemblance': 'Does the observed page resemble the reviewed Brand?',
28
+ 'controlled-change': 'Which registration and DNS dependencies need review?',
29
+ 'incident-response': 'What evidence and recipient route support a response?',
30
+ 'network-context': 'What does the authoritative network record establish?',
31
+ });
32
+ /** A presentation of existing readiness, not a new verdict or collection plan. */
33
+ export function lookupQuestionsNeedingEvidence(readiness) {
34
+ return readiness.entries.filter((entry) => entry.state !== 'ready').map((entry) => ({
35
+ id: entry.id,
36
+ question: CLAIM_QUESTIONS[entry.id],
37
+ href: entry.href,
38
+ requirements: entry.requirements.filter((requirement) => requirement.state !== 'complete'),
39
+ limitations: entry.limitations,
40
+ }));
41
+ }
24
42
  const MAX_TEXT = 320;
25
43
  const MAX_DIAGNOSTICS = 12;
26
44
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
@@ -40,7 +40,7 @@ export function buildLookupInvestigationBrief(input) {
40
40
  const limitedSources = input.quality.entries
41
41
  .filter((entry) => entry.state !== 'complete')
42
42
  .map((entry) => `${entry.label}: ${entry.statusLabel}`);
43
- const graphKinds = uniqueText(input.graph.edges.map((edge) => edge.label), 12);
43
+ const graphKinds = uniqueText(input.graph.edges.map((edge) => edge.label), 64);
44
44
  const brief = Object.freeze({
45
45
  schema: LOOKUP_INVESTIGATION_BRIEF_SCHEMA,
46
46
  schemaVersion: LOOKUP_INVESTIGATION_BRIEF_VERSION,
@@ -66,10 +66,10 @@ export function buildLookupInvestigationBrief(input) {
66
66
  kinds: Object.freeze(graphKinds),
67
67
  }),
68
68
  limitations: Object.freeze(uniqueText([
69
- ...limitedSources,
70
- ...input.graph.limitations,
71
69
  'This brief is a deterministic organisation of collected and derived evidence, not an attribution, ownership, safety, availability, or maliciousness conclusion.',
72
70
  'Analyst assertions, hypotheses, and decisions must remain separate from observed evidence.',
71
+ ...input.graph.limitations,
72
+ ...limitedSources,
73
73
  ])),
74
74
  });
75
75
  if (new TextEncoder().encode(JSON.stringify(brief)).byteLength > MAX_LOOKUP_INVESTIGATION_BRIEF_BYTES) {
@@ -0,0 +1,58 @@
1
+ export const LOOKUP_GUIDANCE_TASKS = Object.freeze([
2
+ 'general',
3
+ 'acquisition',
4
+ 'brand',
5
+ 'incident',
6
+ 'owned',
7
+ ]);
8
+ const GUIDANCE = Object.freeze({
9
+ general: Object.freeze({
10
+ task: 'general',
11
+ label: 'General investigation',
12
+ recommendation: 'fast',
13
+ reason: 'Fast starts with authority-aware registration and availability evidence before a broader collection is justified.',
14
+ requestExplanation: 'Fast requests the existing lower-request authority and RDAP registration path and omits WHOIS, DNS, HTTP, TLS, network-context, and optional intelligence branches.',
15
+ limitation: 'A Fast result can still be partial, unavailable, or inconclusive. Use Deep only when the question needs the additional source families.',
16
+ }),
17
+ brand: Object.freeze({
18
+ task: 'brand',
19
+ label: 'Brand review',
20
+ recommendation: 'deep',
21
+ reason: 'Deep provides the existing source-qualified web, DNS, TLS, registration, and relationship context used by brand review.',
22
+ requestExplanation: 'Deep requests the existing registry RDAP, WHOIS, domain evidence, registrar RDAP, DNS, HTTP, TLS, and network-context branches. Optional intelligence sources receive data only when separately selected.',
23
+ limitation: 'Deep is broader, not complete or authoritative for every question. Missing and conflicting sources remain explicit.',
24
+ }),
25
+ acquisition: Object.freeze({
26
+ task: 'acquisition',
27
+ label: 'Acquisition review',
28
+ recommendation: 'deep',
29
+ reason: 'Deep provides the existing bounded registration, web, DNS, TLS, and uncertainty context expected by acquisition review.',
30
+ requestExplanation: 'Deep uses the existing request budget and source branches. It does not appraise a domain, contact a seller, or establish clean title or legal sufficiency.',
31
+ limitation: 'The recommendation organises due diligence; it does not make an acquisition recommendation or guarantee completeness.',
32
+ }),
33
+ incident: Object.freeze({
34
+ task: 'incident',
35
+ label: 'Incident response',
36
+ recommendation: 'deep',
37
+ reason: 'Deep collects the web, DNS, TLS, registration, and network evidence needed to review an incident and choose a response route.',
38
+ requestExplanation: 'Deep requests the existing source branches. It does not submit a report, test a published contact, or establish responsibility for the observed content.',
39
+ limitation: 'Collection supports review; it does not determine maliciousness or authorise a response.',
40
+ }),
41
+ owned: Object.freeze({
42
+ task: 'owned',
43
+ label: 'Owned-domain posture',
44
+ recommendation: 'deep',
45
+ reason: 'Deep collects the current registration, DNS, TLS, mail, and web observations needed for a source-by-source posture review.',
46
+ requestExplanation: 'Deep uses the existing bounded source branches. Retained baselines remain separate and must be compared using their observation times and source states.',
47
+ limitation: 'A changed or missing observation does not by itself establish compromise, remediation, or control.',
48
+ }),
49
+ });
50
+ export const LOOKUP_TASK_VIEWS = Object.freeze(LOOKUP_GUIDANCE_TASKS.map((task) => Object.freeze({
51
+ id: task,
52
+ label: GUIDANCE[task].label,
53
+ })));
54
+ export function lookupTaskGuidance(task) {
55
+ return typeof task === 'string' && LOOKUP_GUIDANCE_TASKS.includes(task)
56
+ ? GUIDANCE[task]
57
+ : GUIDANCE.general;
58
+ }
@@ -1,3 +1,5 @@
1
+ import { sha256IdentityHex } from "../evidence/record-identity.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
1
3
  /**
2
4
  * Canonical, framework-independent analyst review identity and lifecycle.
3
5
  *
@@ -7,7 +9,12 @@
7
9
  */
8
10
  import { ANALYST_REVIEW_DISPOSITIONS, ANALYST_REVIEW_EVIDENCE_FAMILIES, ANALYST_REVIEW_STATE_SCHEMA, ANALYST_REVIEW_STATE_SCHEMA_VERSION, MAX_ANALYST_REVIEW_ASSOCIATIONS, MAX_ANALYST_REVIEW_HISTORY, MAX_ANALYST_REVIEW_IDENTITY_BYTES, MAX_ANALYST_REVIEW_ITEMS, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, MAX_ANALYST_REVIEW_STATE_BYTES, MAX_ANALYST_REVIEW_STATE_RECORDS, } from "../contracts/analyst-review-state-contract.mjs";
9
11
  export * from "../contracts/analyst-review-state-contract.mjs";
12
+ export { ANALYST_REVIEW_AGING_AFTER_DAYS, ANALYST_REVIEW_STALE_AFTER_DAYS, analystReviewAgeAt } from "../evidence/review-age.mjs";
13
+ export function analystReviewCanResolve(item) {
14
+ return item.completeness === 'complete' && (item.age === 'current' || item.age === 'aging');
15
+ }
10
16
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
17
+ const RATIONALE_INPUT_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
11
18
  const SAFE_REFERENCE_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
12
19
  const SAFE_SUBJECT_RE = /^review:[a-z0-9_]{1,40}:[a-f0-9]{64}$/u;
13
20
  const SAFE_FINGERPRINT_RE = /^material:[a-f0-9]{64}$/u;
@@ -122,28 +129,24 @@ export function assertAnalystReviewInputGraph(value) {
122
129
  }
123
130
  }
124
131
  }
125
- function boundedText(value, maximum, label, required = true) {
126
- if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value)) {
127
- if (!required && (value === null || value === undefined || value === ''))
128
- return '';
132
+ function boundedRationale(value, label, formInput = false) {
133
+ const maximum = MAX_ANALYST_REVIEW_RATIONALE_LENGTH;
134
+ const controls = formInput ? RATIONALE_INPUT_CONTROL_RE : CONTROL_RE;
135
+ if (typeof value !== 'string' || value.length > maximum * 4 || controls.test(value)) {
129
136
  throw inputError(`${label} is invalid`);
130
137
  }
138
+ // Forms may contain ordinary line breaks; stored rationale remains single-line.
131
139
  const normalized = value.replace(/\s+/gu, ' ').trim();
132
- if ((required && !normalized) || normalized.length > maximum)
140
+ if (!normalized || normalized.length > maximum)
133
141
  throw inputError(`${label} is invalid`);
134
142
  return normalized;
135
143
  }
136
144
  function timestamp(value, label, nullable = false) {
137
145
  if (nullable && (value === null || value === undefined || value === ''))
138
146
  return null;
139
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
140
- throw inputError(`${label} is invalid`);
141
- const parsed = Date.parse(value);
142
- if (!Number.isFinite(parsed))
143
- throw inputError(`${label} must use an explicit valid date and time`);
144
- const normalized = new Date(parsed).toISOString();
145
- if (!/(?:Z|[+-]\d{2}:\d{2})$/u.test(value))
146
- throw inputError(`${label} must use an explicit timezone`);
147
+ const normalized = normalizeExplicitIsoTimestamp(value);
148
+ if (!normalized)
149
+ throw inputError(`${label} must use an explicit valid date, time and timezone`);
147
150
  return normalized;
148
151
  }
149
152
  function referenceList(value, label) {
@@ -181,7 +184,7 @@ function normalizeSnapshot(raw, label) {
181
184
  const normalized = {
182
185
  reviewedFingerprint: fingerprint(value.reviewedFingerprint, `${label}.reviewedFingerprint`),
183
186
  disposition: normalizedDisposition,
184
- rationale: boundedText(value.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, `${label}.rationale`),
187
+ rationale: boundedRationale(value.rationale, `${label}.rationale`),
185
188
  reviewedAt: timestamp(value.reviewedAt, `${label}.reviewedAt`),
186
189
  reviewDueAt: timestamp(value.reviewDueAt, `${label}.reviewDueAt`, true),
187
190
  expiresAt: timestamp(value.expiresAt, `${label}.expiresAt`, true),
@@ -278,7 +281,7 @@ export function normalizeAnalystReviewStateStore(raw) {
278
281
  records,
279
282
  };
280
283
  if (byteLength(JSON.stringify(store)) > MAX_ANALYST_REVIEW_STATE_BYTES) {
281
- throw new Error('Analyst review state exceeds the 512 KiB browser-local storage limit.');
284
+ throw new Error('Analyst review state exceeds the 512 KiB workspace storage limit.');
282
285
  }
283
286
  return store;
284
287
  }
@@ -323,75 +326,8 @@ export function canonicalAnalystReviewIdentityJson(parts) {
323
326
  }
324
327
  return source;
325
328
  }
326
- const SHA256_CONSTANTS = Object.freeze([
327
- 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
328
- 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
329
- 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
330
- 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
331
- 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
332
- 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
333
- 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
334
- 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
335
- ]);
336
- function rotateRight(value, bits) {
337
- return (value >>> bits) | (value << (32 - bits));
338
- }
339
- function sha256Hex(bytes) {
340
- // This digest is a collision-resistant record identity, not a password,
341
- // signature, MAC, or substitute for the platform cryptography used there.
342
- const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
343
- const padded = new Uint8Array(paddedLength);
344
- padded.set(bytes);
345
- padded[bytes.byteLength] = 0x80;
346
- const view = new DataView(padded.buffer);
347
- const bitLength = bytes.byteLength * 8;
348
- view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
349
- view.setUint32(paddedLength - 4, bitLength >>> 0, false);
350
- const state = new Uint32Array([
351
- 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
352
- 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
353
- ]);
354
- const words = new Uint32Array(64);
355
- for (let offset = 0; offset < paddedLength; offset += 64) {
356
- for (let index = 0; index < 16; index += 1)
357
- words[index] = view.getUint32(offset + index * 4, false);
358
- for (let index = 16; index < 64; index += 1) {
359
- const left = words[index - 15];
360
- const right = words[index - 2];
361
- const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
362
- const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
363
- words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
364
- }
365
- let [a, b, c, d, e, f, g, h] = state;
366
- for (let index = 0; index < 64; index += 1) {
367
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
368
- const choice = (e & f) ^ (~e & g);
369
- const first = (h + sum1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
370
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
371
- const majority = (a & b) ^ (a & c) ^ (b & c);
372
- const second = (sum0 + majority) >>> 0;
373
- h = g;
374
- g = f;
375
- f = e;
376
- e = (d + first) >>> 0;
377
- d = c;
378
- c = b;
379
- b = a;
380
- a = (first + second) >>> 0;
381
- }
382
- state[0] = (state[0] + a) >>> 0;
383
- state[1] = (state[1] + b) >>> 0;
384
- state[2] = (state[2] + c) >>> 0;
385
- state[3] = (state[3] + d) >>> 0;
386
- state[4] = (state[4] + e) >>> 0;
387
- state[5] = (state[5] + f) >>> 0;
388
- state[6] = (state[6] + g) >>> 0;
389
- state[7] = (state[7] + h) >>> 0;
390
- }
391
- return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
392
- }
393
329
  function stableHash(parts) {
394
- return sha256Hex(new TextEncoder().encode(canonicalAnalystReviewIdentityJson(parts)));
330
+ return sha256IdentityHex(new TextEncoder().encode(canonicalAnalystReviewIdentityJson(parts)));
395
331
  }
396
332
  export function analystReviewSubjectKey(evidenceFamily, stableParts) {
397
333
  if (!FAMILY_VALUES.has(evidenceFamily))
@@ -421,6 +357,11 @@ function snapshotIdentity(snapshot) {
421
357
  snapshot.expiresAt,
422
358
  ]);
423
359
  }
360
+ export function analystReviewDecisionIdentity(subjectKey, snapshot) {
361
+ if (!SAFE_SUBJECT_RE.test(subjectKey))
362
+ throw inputError('Review subject identity is invalid');
363
+ return `decision:${stableHash([subjectKey, snapshotIdentity(snapshot)])}`;
364
+ }
424
365
  function mergedReviewHistory(current, other) {
425
366
  const currentIdentity = snapshotIdentity(currentSnapshot(current));
426
367
  const byIdentity = new Map();
@@ -445,10 +386,10 @@ export function setAnalystReviewDecision(raw, item, input) {
445
386
  }
446
387
  if (!DISPOSITION_VALUES.has(input.disposition))
447
388
  throw new TypeError('Choose a valid Review Item disposition.');
448
- if (input.disposition === 'resolved' && (item.completeness !== 'complete' || item.age === 'stale')) {
449
- throw new Error('Partial, inconclusive, or stale evidence cannot resolve a Review Item. Refresh or attach current complete evidence first.');
389
+ if (input.disposition === 'resolved' && !analystReviewCanResolve(item)) {
390
+ throw new Error('Partial, inconclusive, stale or undated evidence cannot resolve a Review Item. Refresh or attach current complete evidence first.');
450
391
  }
451
- const rationale = boundedText(input.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, 'rationale');
392
+ const rationale = boundedRationale(input.rationale, 'rationale', true);
452
393
  const reviewedAt = timestamp(input.reviewedAt ?? new Date().toISOString(), 'reviewedAt');
453
394
  const expiresAt = timestamp(input.expiresAt, 'expiresAt', true);
454
395
  const reviewDueAt = timestamp(input.reviewDueAt, 'reviewDueAt', true);
@@ -492,7 +433,14 @@ export function analystReviewLifecycle(item, raw, nowRaw = new Date().toISOStrin
492
433
  if (!decision) {
493
434
  return { state: 'open', effectiveDisposition: 'open', decision: null, reason: 'No analyst lifecycle decision is retained.', expired: false, invalidated: false, recurred: false, reviewDue: false };
494
435
  }
495
- const now = timestamp(nowRaw, 'now');
436
+ const now = normalizeExplicitIsoTimestamp(nowRaw);
437
+ if (!now || Date.parse(now) < Date.parse(decision.reviewedAt)) {
438
+ return {
439
+ state: 'invalidated', effectiveDisposition: 'open', decision,
440
+ reason: 'The review clock is unavailable or earlier than the retained decision. Its disposition cannot hide current work.',
441
+ expired: false, invalidated: true, recurred: decision.disposition !== 'open', reviewDue: false,
442
+ };
443
+ }
496
444
  const invalidated = decision.reviewedFingerprint !== item.materialFingerprint;
497
445
  const expired = decision.expiresAt !== null && Date.parse(decision.expiresAt) <= Date.parse(now);
498
446
  const reviewDue = decision.reviewDueAt !== null && Date.parse(decision.reviewDueAt) <= Date.parse(now);
@@ -518,10 +466,10 @@ export function analystReviewLifecycle(item, raw, nowRaw = new Date().toISOStrin
518
466
  expired: false, invalidated: false, recurred: true, reviewDue: true,
519
467
  };
520
468
  }
521
- if (decision.disposition === 'resolved' && (item.completeness !== 'complete' || item.age === 'stale')) {
469
+ if (decision.disposition === 'resolved' && !analystReviewCanResolve(item)) {
522
470
  return {
523
471
  state: 'invalidated', effectiveDisposition: 'open', decision,
524
- reason: 'The current evidence is partial, inconclusive, or stale, so the retained resolved decision cannot close this item.',
472
+ reason: 'The current evidence is partial, inconclusive, stale or undated, so the retained resolved decision cannot close this item.',
525
473
  expired: false, invalidated: true, recurred: true, reviewDue,
526
474
  };
527
475
  }
@@ -607,7 +555,7 @@ function normalizeDevelopmentSnapshot(raw, label) {
607
555
  return {
608
556
  reviewedFingerprint: developmentFingerprint(value.reviewedFingerprint, `${label}.reviewedFingerprint`),
609
557
  disposition: disposition(value.disposition, `${label}.disposition`),
610
- rationale: boundedText(value.rationale, MAX_ANALYST_REVIEW_RATIONALE_LENGTH, `${label}.rationale`),
558
+ rationale: boundedRationale(value.rationale, `${label}.rationale`),
611
559
  reviewedAt,
612
560
  reviewDueAt: reviewDueAt && Date.parse(reviewDueAt) > Date.parse(reviewedAt) ? reviewDueAt : null,
613
561
  expiresAt: expiresAt && Date.parse(expiresAt) > Date.parse(reviewedAt) ? expiresAt : null,
@@ -3,6 +3,11 @@
3
3
  // bounds, import merging, and exact serialized-byte accounting.
4
4
  import { normalizeDomain } from "../cases/case-model.mjs";
5
5
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { canonicalDomainControlRecords, normalizeDeclaredDomainControlRecordModes, normalizeDomainControlRecordModes } from "../evidence/domain-control-runtime.mjs";
7
+ import { DOMAIN_CONTROL_RECORD_LIST_FIELDS } from "../contracts/domain-control-manifest.mjs";
8
+ import { latestObservationCohort } from "../evidence/latest-observations.mjs";
9
+ import { MAX_IDENTITY_DIGEST_BYTES, sha256IdentityHex } from "../evidence/record-identity.mjs";
10
+ import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECKS, MAX_POSTURE_CHECK_RECORDS, MAX_POSTURE_RECORD_LENGTH, normalizeDomainPostureSourceContext, normalizeDomainPostureProfileContext } from "../evidence/domain-posture-context.mjs";
6
11
  import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
7
12
  import { normalizePageBaseline } from "./page-baseline.mjs";
8
13
  import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
@@ -118,6 +123,32 @@ export function normalizeProfileDomains(value) {
118
123
  export function normalizeProfileTextValues(value) {
119
124
  return normalizeList(value, (item) => boundedText(item));
120
125
  }
126
+ export const MAX_ALLOWLIST_DRAFT_CHARACTERS = (MAX_PROFILE_DOMAIN_LENGTH + 2) * MAX_PROFILE_VALUE_INPUTS;
127
+ /** Admit the complete submitted list or leave the draft unchanged. */
128
+ export function addBrandAllowlistValues(profile, kind, current, raw) {
129
+ if (raw.length > MAX_ALLOWLIST_DRAFT_CHARACTERS)
130
+ throw new RangeError('The submitted allowlist text is too large.');
131
+ const inputs = raw.split(/[\n,]+/u).map((item) => item.trim()).filter(Boolean);
132
+ if (inputs.length > MAX_PROFILE_VALUE_INPUTS)
133
+ throw new RangeError(`Review at most ${MAX_PROFILE_VALUE_INPUTS} entries at a time.`);
134
+ const next = new Map(current.map((value) => [value.toLowerCase(), value]));
135
+ const trusted = new Set([...profile.officialDomains, ...profile.approvedPartnerDomains]);
136
+ for (const [index, input] of inputs.entries()) {
137
+ const value = kind === 'domains' ? normalizeProfileDomains([input])[0] : normalizeProfileTextValues([input])[0];
138
+ if (!value || (kind === 'registrars' && input.replace(/\s+/gu, ' ').length > MAX_PROFILE_TEXT_LENGTH)) {
139
+ throw new TypeError(`Allowlist entry ${index + 1} is invalid or too long. No entries were added.`);
140
+ }
141
+ if (kind === 'domains' && trusted.has(value))
142
+ continue;
143
+ if (!next.has(value.toLowerCase()))
144
+ next.set(value.toLowerCase(), value);
145
+ }
146
+ if (next.size > MAX_PROFILE_VALUES)
147
+ throw new RangeError(`This list would contain ${next.size} entries; the current profile supports ${MAX_PROFILE_VALUES}. No entries were added.`);
148
+ if (next.size === current.length)
149
+ throw new TypeError('No new entries remain after excluding existing and trusted values.');
150
+ return [...next.values()];
151
+ }
121
152
  export function normalizeProfileTlds(value) {
122
153
  return normalizeList(value, normalizeTld);
123
154
  }
@@ -159,6 +190,30 @@ export function normalizeProtectionAttestations(value) {
159
190
  }
160
191
  return output;
161
192
  }
193
+ /** Only submitted, validated reviews acquire a new analyst-review clock. */
194
+ export function reviewProtectionAttestations(existing, reviews, nowIso) {
195
+ const assertedAt = timestamp(nowIso, null);
196
+ if (!assertedAt)
197
+ throw new TypeError('The account-control review time is invalid.');
198
+ if (reviews.length > MAX_PROTECTION_ATTESTATIONS)
199
+ throw new RangeError('Too many account-control reviews were submitted.');
200
+ const next = new Map(existing.map((item) => [item.control, { ...item }]));
201
+ const seen = new Set();
202
+ for (const review of reviews) {
203
+ if (!PROTECTION_ATTESTATION_CONTROL_SET.has(review.control) || seen.has(review.control)
204
+ || !PROTECTION_ATTESTATION_STATES.has(review.state))
205
+ throw new TypeError('An account-control review is invalid or repeated.');
206
+ const expiresAt = review.expiresAt === null ? null : timestamp(review.expiresAt, null);
207
+ if (review.expiresAt !== null && !expiresAt)
208
+ throw new TypeError('Enter a valid account-control expiry date.');
209
+ if (typeof review.note !== 'string' || CONTROL_RE.test(review.note)
210
+ || review.note.replace(/\s+/gu, ' ').trim().length > MAX_PROFILE_TEXT_LENGTH)
211
+ throw new TypeError(`Account-control notes must contain at most ${MAX_PROFILE_TEXT_LENGTH} characters and no control characters.`);
212
+ seen.add(review.control);
213
+ next.set(review.control, { control: review.control, state: review.state, assertedAt, expiresAt, note: boundedText(review.note) });
214
+ }
215
+ return [...next.values()];
216
+ }
162
217
  function normalizeDesiredPostureRecords(value, normalizer) {
163
218
  if (!Array.isArray(value))
164
219
  return [];
@@ -176,9 +231,10 @@ function normalizeDesiredPostureRecords(value, normalizer) {
176
231
  }
177
232
  function normalizeDesiredPostureObservation(value) {
178
233
  const candidate = record(value);
179
- const observedAt = timestamp(candidate.observedAt, null);
180
- if (!observedAt || !Array.isArray(candidate.checks))
234
+ const observedAt = normalizeExplicitIsoTimestamp(candidate.observedAt) ?? '';
235
+ if (!Array.isArray(candidate.checks))
181
236
  return null;
237
+ const context = normalizeDomainPostureProfileContext(candidate.context);
182
238
  const checks = [];
183
239
  const seen = new Set();
184
240
  for (const item of candidate.checks.slice(0, 64)) {
@@ -190,28 +246,88 @@ function normalizeDesiredPostureObservation(value) {
190
246
  || !POSTURE_CHECK_STATUSES.has(check.status))
191
247
  continue;
192
248
  seen.add(id);
249
+ if (check.sourceContext !== undefined && !Array.isArray(check.records))
250
+ throw new TypeError('Source-qualified posture records must be an array.');
251
+ const inputRecords = Array.isArray(check.records) ? check.records : [];
252
+ const records = [];
253
+ for (const item of inputRecords.slice(0, MAX_POSTURE_CHECK_RECORDS)) {
254
+ if (typeof item === 'string' && item.length <= MAX_POSTURE_RECORD_LENGTH && !CONTROL_RE.test(item))
255
+ records.push(item);
256
+ }
257
+ const omitted = Math.max(0, inputRecords.length - records.length);
258
+ const source = normalizeDomainPostureSourceContext(check.sourceContext);
259
+ const sourceContext = source && omitted ? {
260
+ ...source,
261
+ state: source.state === 'unavailable' ? 'unavailable' : 'partial',
262
+ omittedRecords: source.omittedRecords === null ? null : Math.min(Number.MAX_SAFE_INTEGER, source.omittedRecords + omitted),
263
+ } : source;
193
264
  checks.push({
194
265
  id,
195
266
  status: check.status,
196
- records: normalizeDesiredPostureRecords(check.records),
267
+ records,
268
+ ...(sourceContext ? { sourceContext } : {}),
197
269
  });
198
- if (checks.length >= 32)
270
+ if (checks.length >= MAX_POSTURE_CHECKS)
199
271
  break;
200
272
  }
201
- return checks.length ? { observedAt, checks } : null;
273
+ const omittedChecks = Math.max(0, candidate.checks.length - checks.length)
274
+ + (Number.isSafeInteger(candidate.omittedChecks) && Number(candidate.omittedChecks) > 0 ? Number(candidate.omittedChecks) : 0);
275
+ return checks.length ? { observedAt, ...(context ? { context } : {}), checks, ...(omittedChecks ? { omittedChecks: Math.min(Number.MAX_SAFE_INTEGER, omittedChecks) } : {}) } : null;
202
276
  }
203
- function normalizeDesiredPostureObservationHistory(value, previous) {
277
+ export function normalizeDesiredPostureObservationHistory(value, previous) {
204
278
  const candidates = Array.isArray(value) ? value : previous ? [previous] : [];
205
279
  const byTime = new Map();
206
280
  for (const item of candidates.slice(0, MAX_DESIRED_POSTURE_OBSERVATIONS * 4)) {
207
281
  const normalized = normalizeDesiredPostureObservation(item);
208
- if (normalized)
209
- byTime.set(normalized.observedAt, normalized);
282
+ if (!normalized)
283
+ continue;
284
+ if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > MAX_IDENTITY_DIGEST_BYTES) {
285
+ throw new RangeError('Record identity exceeds its byte limit.');
286
+ }
287
+ const cohort = byTime.get(normalized.observedAt) ?? [];
288
+ cohort.push(normalized);
289
+ byTime.set(normalized.observedAt, cohort);
210
290
  }
211
- return [...byTime.values()]
212
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
291
+ return [...byTime.entries()]
292
+ .sort(([left], [right]) => !left ? 1 : !right ? -1 : Date.parse(left) - Date.parse(right))
293
+ .flatMap(([, cohort]) => {
294
+ // Different capture times already distinguish observations. Digest-based
295
+ // deduplication and ordering remain necessary only within a tied cohort.
296
+ if (cohort.length === 1)
297
+ return cohort;
298
+ const byIdentity = new Map(cohort.map((observation) => [desiredPostureObservationIdentity(observation), observation]));
299
+ return [...byIdentity.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
300
+ .map(([, observation]) => observation);
301
+ })
213
302
  .slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
214
303
  }
304
+ export function desiredPostureObservationIdentity(observation) {
305
+ return sha256IdentityHex(new TextEncoder().encode(JSON.stringify(observation)));
306
+ }
307
+ export function desiredPostureObservations(baseline) {
308
+ return baseline.observationHistory?.length ? baseline.observationHistory : baseline.previousObservation ? [baseline.previousObservation] : [];
309
+ }
310
+ export function currentDesiredPostureObservation(baseline) {
311
+ const history = desiredPostureObservations(baseline);
312
+ const cohort = latestObservationCohort(history, (item) => item.observedAt);
313
+ return {
314
+ observation: !cohort.undated.length && cohort.latest.length === 1 ? cohort.latest[0] : null,
315
+ candidates: [...cohort.latest, ...cohort.undated],
316
+ limitation: cohort.undated.length ? 'Retained observations include an unknown capture time; no unique latest review is selected.'
317
+ : cohort.latest.length > 1 ? 'Distinct observations share the latest capture time; no unique latest review is selected.' : null,
318
+ };
319
+ }
320
+ export function brandPostureCollectionFingerprint(profile) {
321
+ return sha256IdentityHex(new TextEncoder().encode(JSON.stringify([
322
+ profile.id, [...profile.officialDomains].sort(), profile.mailProtectionProfile,
323
+ [...profile.dkimSelectors].sort(), [...profile.retiredDkimSelectors].sort(),
324
+ ])));
325
+ }
326
+ export function brandPostureObservationContext(profile, domain) {
327
+ if (!profile.officialDomains.includes(domain))
328
+ throw new TypeError('Posture observation target is not an official domain of this profile.');
329
+ return { version: DOMAIN_POSTURE_COMPARISON_VERSION, domain, profileId: profile.id, profileFingerprint: brandPostureCollectionFingerprint(profile) };
330
+ }
215
331
  function deterministicChangeWindowId(seed) {
216
332
  const states = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35];
217
333
  const factors = [0x01000193, 0x85ebca6b, 0xc2b2ae35, 0x27d4eb2f];
@@ -270,6 +386,23 @@ function normalizeTlsSanPatterns(value) {
270
386
  }
271
387
  return [...output].sort();
272
388
  }
389
+ function normalizeBaselineRecords(candidate) {
390
+ const records = {
391
+ nameservers: normalizeDesiredPostureRecords(candidate.nameservers, (entry) => normalizeDomain(entry)),
392
+ ds: normalizeDesiredPostureRecords(candidate.ds),
393
+ mx: normalizeDesiredPostureRecords(candidate.mx),
394
+ caa: normalizeDesiredPostureRecords(candidate.caa),
395
+ };
396
+ if (!Object.hasOwn(candidate, 'recordModes'))
397
+ return records;
398
+ const recordModes = normalizeDeclaredDomainControlRecordModes(candidate.recordModes);
399
+ for (const field of DOMAIN_CONTROL_RECORD_LIST_FIELDS) {
400
+ if (Object.hasOwn(recordModes, field))
401
+ records[field] = canonicalDomainControlRecords(candidate[field], field);
402
+ }
403
+ normalizeDomainControlRecordModes(recordModes, records);
404
+ return { ...records, recordModes };
405
+ }
273
406
  export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString(), identityScope = '') {
274
407
  if (!Array.isArray(value))
275
408
  return [];
@@ -306,10 +439,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
306
439
  output.push({
307
440
  version: 1,
308
441
  domain,
309
- nameservers: normalizeDesiredPostureRecords(candidate.nameservers, (entry) => normalizeDomain(entry)),
310
- ds: normalizeDesiredPostureRecords(candidate.ds),
311
- mx: normalizeDesiredPostureRecords(candidate.mx),
312
- caa: normalizeDesiredPostureRecords(candidate.caa),
442
+ ...normalizeBaselineRecords(candidate),
313
443
  tlsIssuer: boundedText(candidate.tlsIssuer, MAX_PROFILE_TEXT_LENGTH),
314
444
  tlsSanPatterns: normalizeTlsSanPatterns(candidate.tlsSanPatterns),
315
445
  tlsSpkiSha256: typeof candidate.tlsSpkiSha256 === 'string' && SHA256_RE.test(candidate.tlsSpkiSha256)
@@ -329,7 +459,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
329
459
  approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows, `${identityScope}\u001f${domain}`),
330
460
  suppressions,
331
461
  note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
332
- previousObservation: observationHistory.at(-1) ?? previousObservation,
462
+ previousObservation: currentDesiredPostureObservation({ observationHistory, previousObservation }).observation,
333
463
  observationHistory,
334
464
  updatedAt: timestamp(candidate.updatedAt, fallback),
335
465
  });
@@ -510,7 +640,7 @@ export function brandProfileStoreVersion(raw) {
510
640
  }
511
641
  /** Normalize an internal profile collection or current stored envelope. */
512
642
  export function normalizeBrandProfileStore(raw) {
513
- assertWorkspaceInputGraph(raw, 'Brand Profile store');
643
+ assertWorkspaceInputGraph(raw, 'Brand Profile store', { maximumBytes: MAX_PROFILE_STORE_BYTES });
514
644
  assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
515
645
  const sourceVersion = brandProfileStoreVersion(raw);
516
646
  if (!Array.isArray(raw) && sourceVersion !== null
@@ -532,6 +662,10 @@ export function normalizeBrandProfileStore(raw) {
532
662
  }
533
663
  return { version: BRAND_PROFILE_SCHEMA_VERSION, profiles: [...byId.values()] };
534
664
  }
665
+ /** New authored/imported records receive an identity only at the mutation boundary. */
666
+ export function createBrandProfileId() {
667
+ return crypto.randomUUID ? crypto.randomUUID() : `bp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
668
+ }
535
669
  export function serializeBrandProfileStore(profiles) {
536
670
  return JSON.stringify(assertBrandProfileStoreBudget(profiles));
537
671
  }
@@ -546,8 +680,8 @@ export function assertBrandProfileStoreBudget(profiles) {
546
680
  return store;
547
681
  }
548
682
  export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
549
- assertWorkspaceInputGraph(localRaw, 'Local Brand Profile store');
550
- assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document');
683
+ const local = normalizeBrandProfileStore(localRaw).profiles;
684
+ assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document', { maximumBytes: MAX_PROFILE_STORE_BYTES });
551
685
  assertWorkspacePortableVersion(importedRaw, BRAND_PROFILE_SCHEMA_VERSION, 'Imported Brand Profile document');
552
686
  const imported = record(importedRaw);
553
687
  if (imported.schema !== BRAND_PROFILE_SCHEMA) {
@@ -579,7 +713,6 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
579
713
  if (id && name)
580
714
  retainIdName(id, name);
581
715
  }
582
- const local = normalizeBrandProfileStore(localRaw).profiles;
583
716
  for (const profile of local)
584
717
  retainIdName(profile.id, profile.name);
585
718
  const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
@@ -594,9 +727,14 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
594
727
  if (rawId && rawName)
595
728
  retainIdName(rawId, rawName);
596
729
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
730
+ const incomingUpdatedAt = timestamp(value.updatedAt, null);
731
+ if (existing && (!incomingUpdatedAt || incomingUpdatedAt <= existing.updatedAt)) {
732
+ skipped++;
733
+ continue;
734
+ }
597
735
  const profile = normalizeBrandProfile(item, {
598
736
  existing,
599
- touch: Boolean(existing),
737
+ touch: false,
600
738
  nowIso: options.nowIso,
601
739
  makeId: options.makeId,
602
740
  });
@@ -0,0 +1,17 @@
1
+ /** Optional result columns. Domain identity and actions are always available. */
2
+ export const BULK_RESULT_COLUMNS = Object.freeze([
3
+ { id: 'registration', label: 'Registration' },
4
+ { id: 'risk', label: 'Risk' },
5
+ { id: 'website', label: 'Website' },
6
+ { id: 'registrar', label: 'Registrar' },
7
+ { id: 'mutation', label: 'Mutation' },
8
+ { id: 'review', label: 'Review' },
9
+ { id: 'case', label: 'Case' },
10
+ ]);
11
+ export const DEFAULT_BULK_RESULT_COLUMNS = Object.freeze(BULK_RESULT_COLUMNS.map((column) => column.id));
12
+ export function normalizeBulkResultColumns(value) {
13
+ // Missing choices preserve the full presentation used by public version 1.
14
+ if (!Array.isArray(value))
15
+ return [...DEFAULT_BULK_RESULT_COLUMNS];
16
+ return BULK_RESULT_COLUMNS.filter((column) => value.includes(column.id)).map((column) => column.id);
17
+ }