@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

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 (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,31 +1,14 @@
1
1
  // Pure Brand Profile normalization and storage model. The browser wrapper owns
2
2
  // persistence and downloads; this module owns schema migration, semantic field
3
3
  // bounds, import merging, and exact serialized-byte accounting.
4
- import { normalizeDomain } from "./case-model.js";
5
- import { normalizeOpaqueReferenceId } from "./opaque-reference-id.js";
6
- import { normalizePageBaseline } from "./page-baseline.js";
7
- import { isInformativeFaviconHash } from "./utils.js";
8
- export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
9
- export const BRAND_PROFILE_SCHEMA_VERSION = 6;
10
- export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = Object.freeze([2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION]);
11
- export const MAX_PROFILES = 100;
12
- export const MAX_PROFILE_VALUES = 200;
13
- export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
14
- // Profiles share the origin's browser-storage quota with cases, campaigns,
15
- // watchlists, and CT history. Fail at a predictable one-megabyte boundary.
16
- export const MAX_PROFILE_STORE_BYTES = 1024 * 1024;
17
- export const MAX_PROFILE_NAME_LENGTH = 100;
18
- export const MAX_PROFILE_TEXT_LENGTH = 200;
19
- export const MAX_PROFILE_DOMAIN_LENGTH = 253;
20
- export const MAX_PROFILE_TLD_LENGTH = 63;
21
- export const MAX_DKIM_SELECTOR_LENGTH = 253;
22
- export const MAX_DKIM_SELECTORS = 10;
23
- export const MAX_PROTECTION_ATTESTATIONS = 6;
24
- export const MAX_DESIRED_POSTURE_BASELINES = 20;
25
- export const MAX_DESIRED_POSTURE_RECORDS = 32;
26
- export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
27
- export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
28
- export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
4
+ import { normalizeDomain } from "../cases/case-model.mjs";
5
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
7
+ import { normalizePageBaseline } from "./page-baseline.mjs";
8
+ import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
9
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
10
+ import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
11
+ export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
29
12
  const SHA256_RE = /^[a-f0-9]{64}$/i;
30
13
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
31
14
  const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -46,7 +29,7 @@ const PROTECTION_ATTESTATION_STATES = new Set([
46
29
  'unavailable',
47
30
  'not_applicable',
48
31
  ]);
49
- const DESIRED_POSTURE_FIELDS = new Set([
32
+ export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
50
33
  'nameservers',
51
34
  'ds',
52
35
  'mx',
@@ -57,11 +40,10 @@ const DESIRED_POSTURE_FIELDS = new Set([
57
40
  'registrar_lock',
58
41
  'renewal_review',
59
42
  ]);
43
+ const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
60
44
  const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
61
45
  function record(value) {
62
- return value && typeof value === 'object' && !Array.isArray(value)
63
- ? value
64
- : {};
46
+ return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
65
47
  }
66
48
  function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
67
49
  if (typeof value !== 'string' || CONTROL_RE.test(value))
@@ -72,12 +54,13 @@ export function normalizeBrandProfileId(value) {
72
54
  return normalizeOpaqueReferenceId(value);
73
55
  }
74
56
  function timestamp(value, fallback) {
75
- if (typeof value === 'string' && value.length <= 64 && !CONTROL_RE.test(value)) {
76
- const parsed = Date.parse(value);
77
- if (Number.isFinite(parsed))
78
- return new Date(parsed).toISOString();
57
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
58
+ }
59
+ function calendarTimestamp(value, fallback) {
60
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value)) {
61
+ return normalizeExplicitIsoTimestamp(`${value}T00:00:00Z`) ?? fallback;
79
62
  }
80
- return fallback;
63
+ return timestamp(value, fallback);
81
64
  }
82
65
  function normalizeTld(value) {
83
66
  if (typeof value !== 'string' || CONTROL_RE.test(value))
@@ -157,7 +140,7 @@ export function normalizeProtectionAttestations(value) {
157
140
  control: candidate.control,
158
141
  state: candidate.state,
159
142
  assertedAt,
160
- expiresAt: timestamp(candidate.expiresAt, null),
143
+ expiresAt: calendarTimestamp(candidate.expiresAt, null),
161
144
  note: boundedText(candidate.note),
162
145
  });
163
146
  if (output.length >= MAX_PROTECTION_ATTESTATIONS)
@@ -218,10 +201,25 @@ function normalizeDesiredPostureObservationHistory(value, previous) {
218
201
  .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
219
202
  .slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
220
203
  }
221
- function normalizeDesiredPostureChangeWindows(value) {
204
+ function deterministicChangeWindowId(seed) {
205
+ const states = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35];
206
+ const factors = [0x01000193, 0x85ebca6b, 0xc2b2ae35, 0x27d4eb2f];
207
+ for (let index = 0; index < seed.length; index += 1) {
208
+ const code = seed.charCodeAt(index);
209
+ for (let stateIndex = 0; stateIndex < states.length; stateIndex += 1) {
210
+ const state = states[stateIndex] ?? 0;
211
+ const mixed = state ^ (code + Math.imul(index + 1, stateIndex + 17));
212
+ states[stateIndex] = Math.imul(mixed, factors[stateIndex] ?? 0x01000193);
213
+ }
214
+ }
215
+ return `cw-${states.map((state) => (state >>> 0).toString(16).padStart(8, '0')).join('')}`;
216
+ }
217
+ function normalizeDesiredPostureChangeWindows(value, identityScope = '') {
222
218
  if (!Array.isArray(value))
223
219
  return [];
224
220
  const output = [];
221
+ const seenIds = new Set();
222
+ const duplicateMaterial = new Map();
225
223
  for (const item of value.slice(0, MAX_DESIRED_POSTURE_CHANGE_WINDOWS * 4)) {
226
224
  const candidate = record(item);
227
225
  const startsAt = timestamp(candidate.startsAt, null);
@@ -229,7 +227,17 @@ function normalizeDesiredPostureChangeWindows(value) {
229
227
  const summary = boundedText(candidate.summary, 300);
230
228
  if (!startsAt || !endsAt || Date.parse(endsAt) <= Date.parse(startsAt) || !summary)
231
229
  continue;
232
- output.push({ startsAt, endsAt, summary });
230
+ const material = `${startsAt}\u001f${endsAt}\u001f${summary}`;
231
+ const occurrence = duplicateMaterial.get(material) ?? 0;
232
+ duplicateMaterial.set(material, occurrence + 1);
233
+ let id = normalizeOpaqueReferenceId(candidate.id);
234
+ let salt = 0;
235
+ while (!id || seenIds.has(id)) {
236
+ id = deterministicChangeWindowId(`${identityScope}\u001f${material}\u001f${occurrence}\u001f${salt}`);
237
+ salt += 1;
238
+ }
239
+ seenIds.add(id);
240
+ output.push({ id, startsAt, endsAt, summary });
233
241
  if (output.length >= MAX_DESIRED_POSTURE_CHANGE_WINDOWS)
234
242
  break;
235
243
  }
@@ -251,7 +259,7 @@ function normalizeTlsSanPatterns(value) {
251
259
  }
252
260
  return [...output].sort();
253
261
  }
254
- export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString()) {
262
+ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbackNow = new Date().toISOString(), identityScope = '') {
255
263
  if (!Array.isArray(value))
256
264
  return [];
257
265
  const allowedDomains = new Set(officialDomains);
@@ -276,7 +284,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
276
284
  suppressions.push({
277
285
  field,
278
286
  reason,
279
- expiresAt: timestamp(suppression.expiresAt, null),
287
+ expiresAt: calendarTimestamp(suppression.expiresAt, null),
280
288
  });
281
289
  if (suppressions.length >= MAX_DESIRED_POSTURE_SUPPRESSIONS)
282
290
  break;
@@ -299,7 +307,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
299
307
  registrarLock: ['required', 'not_required'].includes(String(candidate.registrarLock))
300
308
  ? candidate.registrarLock
301
309
  : 'unconfigured',
302
- renewalReviewAt: timestamp(candidate.renewalReviewAt, null),
310
+ renewalReviewAt: calendarTimestamp(candidate.renewalReviewAt, null),
303
311
  zoneIntent: ['active_service', 'defensive_registration', 'no_service', 'parked', 'redirect_only'].includes(String(candidate.zoneIntent))
304
312
  ? candidate.zoneIntent
305
313
  : 'unconfigured',
@@ -307,7 +315,7 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
307
315
  ? candidate.lifecycle
308
316
  : 'active',
309
317
  recoveryDependency: boundedText(candidate.recoveryDependency, 200),
310
- approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows),
318
+ approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows, `${identityScope}\u001f${domain}`),
311
319
  suppressions,
312
320
  note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
313
321
  previousObservation: observationHistory.at(-1) ?? previousObservation,
@@ -332,9 +340,12 @@ export function normalizeBrandProfile(raw, options = {}) {
332
340
  const now = timestamp(options.nowIso, new Date().toISOString());
333
341
  const officialDomains = normalizeProfileDomains(value.officialDomains);
334
342
  const dkimSelectors = normalizeDkimSelectors(value.dkimSelectors);
335
- const candidateBaseline = Object.prototype.hasOwnProperty.call(value, 'pageBaseline')
343
+ const suppliedBaseline = Object.prototype.hasOwnProperty.call(value, 'pageBaseline');
344
+ const candidateBaseline = suppliedBaseline
336
345
  ? normalizePageBaseline(value.pageBaseline)
337
346
  : normalizePageBaseline(existing?.pageBaseline);
347
+ const baselineDomainMismatch = suppliedBaseline && candidateBaseline !== null
348
+ && !officialDomains.includes(candidateBaseline.domain);
338
349
  const pageBaseline = candidateBaseline && officialDomains.includes(candidateBaseline.domain)
339
350
  ? candidateBaseline
340
351
  : null;
@@ -359,11 +370,11 @@ export function normalizeBrandProfile(raw, options = {}) {
359
370
  .filter((selector) => !dkimSelectors.includes(selector)),
360
371
  mailProtectionProfile: normalizeMailProtectionProfile(value.mailProtectionProfile),
361
372
  protectionAttestations: normalizeProtectionAttestations(value.protectionAttestations),
362
- desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now),
373
+ desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
363
374
  trademarkOwner: boundedText(value.trademarkOwner),
364
375
  trademarkRegistration: boundedText(value.trademarkRegistration),
365
- officialFaviconHash: normalizeFaviconHash(value.officialFaviconHash),
366
- officialFaviconPHash: normalizeFaviconPHash(value.officialFaviconPHash),
376
+ officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
377
+ officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
367
378
  pageBaseline,
368
379
  createdAt,
369
380
  updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
@@ -377,15 +388,24 @@ function profileList(raw) {
377
388
  }
378
389
  export function brandProfileStoreVersion(raw) {
379
390
  if (Array.isArray(raw))
380
- return 1;
391
+ return BRAND_PROFILE_SCHEMA_VERSION;
381
392
  const value = record(raw);
382
393
  return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
383
394
  }
384
395
  /** Normalize an internal profile collection or current stored envelope. */
385
396
  export function normalizeBrandProfileStore(raw) {
397
+ assertWorkspaceInputGraph(raw, 'Brand Profile store');
398
+ assertWorkspaceDeclaredVersion(raw, 'Brand Profile store');
399
+ const sourceVersion = brandProfileStoreVersion(raw);
400
+ if (!Array.isArray(raw) && sourceVersion !== null
401
+ && !SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(sourceVersion)) {
402
+ throw new Error(`Brand Profile schema ${sourceVersion} is unsupported; no data was changed.`);
403
+ }
386
404
  const byId = new Map();
387
405
  for (const item of profileList(raw).slice(0, MAX_PROFILES * 4)) {
388
- const profile = normalizeBrandProfile(item);
406
+ const profile = normalizeBrandProfile(item, {
407
+ nowIso: new Date(0).toISOString(),
408
+ });
389
409
  if (!profile)
390
410
  continue;
391
411
  const previous = byId.get(profile.id);
@@ -410,6 +430,9 @@ export function assertBrandProfileStoreBudget(profiles) {
410
430
  return store;
411
431
  }
412
432
  export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
433
+ assertWorkspaceInputGraph(localRaw, 'Local Brand Profile store');
434
+ assertWorkspaceInputGraph(importedRaw, 'Imported Brand Profile document');
435
+ assertWorkspacePortableVersion(importedRaw, BRAND_PROFILE_SCHEMA_VERSION, 'Imported Brand Profile document');
413
436
  const imported = record(importedRaw);
414
437
  if (imported.schema !== BRAND_PROFILE_SCHEMA) {
415
438
  throw new Error('This JSON file is not a WHOISleuth Brand Profile export.');
@@ -457,7 +480,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
457
480
  const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
458
481
  const profile = normalizeBrandProfile(item, {
459
482
  existing,
460
- touch: true,
483
+ touch: Boolean(existing),
461
484
  nowIso: options.nowIso,
462
485
  makeId: options.makeId,
463
486
  });
@@ -1,11 +1,9 @@
1
- import { normalizeDomain } from "./case-model.js";
2
- import { normalizeBulkPresentationSortKey } from "./bulk-sort.js";
3
- export const BULK_REVIEW_SCHEMA = 'whoisleuth.bulk-review';
4
- export const BULK_REVIEW_SCHEMA_VERSION = 1;
5
- export const MAX_BULK_REVIEW_PRESETS = 24;
6
- export const MAX_BULK_REVIEW_ROWS = 1_900;
7
- export const MAX_BULK_REVIEW_STORE_BYTES = 512 * 1024;
8
- export const MAX_BULK_REVIEW_NAME_LENGTH = 80;
1
+ import { normalizeDomain } from "../cases/case-model.mjs";
2
+ import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
5
+ import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
6
+ export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
9
7
  export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
10
8
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
11
9
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
@@ -20,9 +18,7 @@ const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
20
18
  const CASE_DISPOSITIONS = new Set(['', 'untracked', 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action']);
21
19
  const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
22
20
  function record(value) {
23
- return value && typeof value === 'object' && !Array.isArray(value)
24
- ? value
25
- : {};
21
+ return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
26
22
  }
27
23
  function text(value, maximum) {
28
24
  if (typeof value !== 'string' || CONTROL_RE.test(value))
@@ -30,9 +26,7 @@ function text(value, maximum) {
30
26
  return value.trim().slice(0, maximum);
31
27
  }
32
28
  function timestamp(value, fallback) {
33
- const normalized = text(value, 64);
34
- const parsed = Date.parse(normalized);
35
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
29
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
36
30
  }
37
31
  function safeId(value) {
38
32
  return typeof value === 'string' && SAFE_ID_RE.test(value) ? value : '';
@@ -111,6 +105,8 @@ function sourceLists(raw) {
111
105
  };
112
106
  }
113
107
  export function normalizeBulkReviewStore(raw) {
108
+ assertWorkspaceInputGraph(raw, 'Bulk-review store');
109
+ assertWorkspaceDeclaredVersion(raw, 'Bulk-review store');
114
110
  const fallback = new Date(0).toISOString();
115
111
  const source = sourceLists(raw);
116
112
  const presets = new Map();
@@ -208,6 +204,9 @@ export function buildBulkReviewExport(raw) {
208
204
  return enforceBulkReviewBudget(raw);
209
205
  }
210
206
  export function mergeBulkReviewStores(localRaw, importedRaw) {
207
+ assertWorkspaceInputGraph(localRaw, 'Local Bulk-review store');
208
+ assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-review document');
209
+ assertWorkspacePortableVersion(importedRaw, BULK_REVIEW_SCHEMA_VERSION, 'Imported Bulk-review document');
211
210
  const importedRecord = record(importedRaw);
212
211
  if (importedRecord.schema !== BULK_REVIEW_SCHEMA) {
213
212
  throw new Error('This file is not a WHOISleuth Bulk review export.');
@@ -1,21 +1,13 @@
1
1
  // Pure, bounded persistence model for analyst-saved Bulk investigations.
2
2
  // Sessions retain compact derived rows and source states, never raw registry,
3
3
  // WHOIS, HTTP, TLS, page, or provider payloads.
4
- import { normalizeDomain } from "./case-model.js";
5
- import { normalizeCaaCritical } from "./dns-record-normalization.js";
6
- export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
7
- export const BULK_SESSION_SCHEMA_VERSION = 4;
8
- export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, BULK_SESSION_SCHEMA_VERSION]);
9
- export const MAX_BULK_SESSIONS = 10;
10
- export const MAX_BULK_SESSION_ROWS = 2_000;
11
- export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
12
- export const MAX_BULK_SESSION_NAME_LENGTH = 100;
13
- export const MAX_BULK_SESSION_TEXT_LENGTH = 500;
14
- export const MAX_BULK_SESSION_ARRAY_VALUES = 100;
15
- export const MAX_BULK_SESSION_SOURCES = 12;
16
- export const MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH = 300;
4
+ import { normalizeDomain } from "../cases/case-model.mjs";
5
+ import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
7
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
8
+ import { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
9
+ export { BULK_SESSION_SCHEMA, BULK_SESSION_SCHEMA_VERSION, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ARRAY_VALUES, MAX_BULK_SESSION_NAME_LENGTH, MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES, MAX_BULK_SESSION_STORE_BYTES, MAX_BULK_SESSION_TEXT_LENGTH, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
17
10
  export const BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION = 'Brand Profile context was unavailable when this row was evaluated. Trust, profile matches, and the profile-dependent Risk assessment remain inconclusive.';
18
- export const BULK_PROFILE_CONTEXT_LEGACY_LIMITATION = 'This legacy Bulk row did not retain profile-context provenance. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned.';
19
11
  export const BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION = 'This saved Bulk row was evaluated with a different or unreadable Brand Profile context. Profile-derived conclusions and the Risk assessment are withheld until the domain is rescanned.';
20
12
  export const BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION = 'This row came from a portable import whose claimed Brand Profile identity and revision cannot authenticate profile-derived conclusions. Trust, profile matches, and the potentially profile-influenced Risk assessment are withheld until the domain is rescanned locally.';
21
13
  const CONTROL_RE = /[\x00-\x1f\x7f]/u;
@@ -41,9 +33,7 @@ const SOURCE_STATES = new Set([
41
33
  const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
42
34
  const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
43
35
  function record(value) {
44
- return value && typeof value === 'object' && !Array.isArray(value)
45
- ? value
46
- : null;
36
+ return ordinaryWorkspaceRecord(value, 'Bulk-session input');
47
37
  }
48
38
  function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
49
39
  return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
@@ -51,8 +41,7 @@ function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
51
41
  : '';
52
42
  }
53
43
  function timestamp(value, fallback = null) {
54
- const text = boundedText(value, 64);
55
- return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
44
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
56
45
  }
57
46
  function profileContextLimitation(value, fallback) {
58
47
  return boundedText(value, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH) || fallback;
@@ -281,7 +270,7 @@ export function normalizeBulkComparisonEvidence(value) {
281
270
  },
282
271
  };
283
272
  }
284
- export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_SCHEMA_VERSION) {
273
+ export function normalizeBulkSessionResult(value) {
285
274
  const item = record(value);
286
275
  const domain = normalizeDomain(item?.domain);
287
276
  const status = boundedText(item?.status, 20);
@@ -289,13 +278,10 @@ export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_S
289
278
  if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
290
279
  return null;
291
280
  const rawProfileContext = record(item.profileContext);
292
- if (sourceVersion >= 4
293
- && (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState))))
281
+ if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
294
282
  return null;
295
- const profileContext = sourceVersion >= 4
296
- ? normalizeBulkProfileContext(rawProfileContext)
297
- : unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_LEGACY_LIMITATION);
298
- if (sourceVersion >= 4 && rawProfileContext?.sourceState === 'ready' && profileContext.sourceState !== 'ready')
283
+ const profileContext = normalizeBulkProfileContext(rawProfileContext);
284
+ if (rawProfileContext.sourceState === 'ready' && profileContext.sourceState !== 'ready')
299
285
  return null;
300
286
  const profileContextReady = profileContext.sourceState === 'ready';
301
287
  const trusted = boundedText(item.trusted, 20);
@@ -357,7 +343,7 @@ export function normalizeBulkSessionResult(value, sourceVersion = BULK_SESSION_S
357
343
  profileContext,
358
344
  };
359
345
  }
360
- export function normalizeBulkSession(value, sourceVersion = BULK_SESSION_SCHEMA_VERSION) {
346
+ export function normalizeBulkSession(value) {
361
347
  const item = record(value);
362
348
  const id = boundedText(item?.id, 128);
363
349
  const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
@@ -379,33 +365,27 @@ export function normalizeBulkSession(value, sourceVersion = BULK_SESSION_SCHEMA_
379
365
  const domains = domainList(item.domains);
380
366
  if (!domains.length)
381
367
  return null;
382
- if (sourceVersion >= 4 && !Array.isArray(item.results))
368
+ if (!Array.isArray(item.results))
383
369
  return null;
384
370
  const allowed = new Set(domains);
385
371
  const results = [];
386
372
  const seen = new Set();
387
373
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
388
- const result = normalizeBulkSessionResult(candidate, sourceVersion);
374
+ const result = normalizeBulkSessionResult(candidate);
389
375
  if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
390
- if (sourceVersion >= 4)
391
- return null;
392
- continue;
376
+ return null;
393
377
  }
394
378
  seen.add(result.domain);
395
379
  results.push(result);
396
- if (sourceVersion < 4 && results.length >= MAX_BULK_SESSION_ROWS)
397
- break;
398
380
  }
399
- if (sourceVersion >= 4 && ((state === 'complete') !== (results.length === domains.length)))
381
+ if ((state === 'complete') !== (results.length === domains.length))
400
382
  return null;
401
383
  const profileContext = summarizeBulkProfileContexts(results);
402
- if (sourceVersion >= 4) {
403
- if (!record(item.profileContext))
404
- return null;
405
- const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
406
- if (!sameProfileContext(declaredProfileContext, profileContext))
407
- return null;
408
- }
384
+ if (!record(item.profileContext))
385
+ return null;
386
+ const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
387
+ if (!sameProfileContext(declaredProfileContext, profileContext))
388
+ return null;
409
389
  return {
410
390
  id,
411
391
  name,
@@ -427,11 +407,13 @@ export function bulkSessionStoreVersion(raw) {
427
407
  : null;
428
408
  }
429
409
  export function normalizeBulkSessionStore(raw) {
410
+ assertWorkspaceInputGraph(raw, 'Bulk-session store');
411
+ assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
430
412
  const value = record(raw);
431
- const sourceVersion = value?.schema === BULK_SESSION_SCHEMA
432
- && SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))
433
- ? Number(value.version)
434
- : null;
413
+ if (value?.schema === BULK_SESSION_SCHEMA
414
+ && !SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(Number(value.version))) {
415
+ throw new Error(`Bulk-session store schema ${String(value.version)} is unsupported; no data was changed.`);
416
+ }
435
417
  const candidates = Array.isArray(raw)
436
418
  ? raw
437
419
  : value?.schema === BULK_SESSION_SCHEMA
@@ -441,17 +423,7 @@ export function normalizeBulkSessionStore(raw) {
441
423
  : [];
442
424
  const byId = new Map();
443
425
  for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
444
- const candidateRecord = record(candidate);
445
- const resultRecords = Array.isArray(candidateRecord?.results)
446
- ? candidateRecord.results.map(record).filter((item) => Boolean(item))
447
- : [];
448
- const whollyUnversionedLegacy = Array.isArray(raw)
449
- && !Object.hasOwn(candidateRecord ?? {}, 'profileContext')
450
- && resultRecords.every((item) => !Object.hasOwn(item, 'profileContext'));
451
- const candidateVersion = Array.isArray(raw)
452
- ? whollyUnversionedLegacy ? 3 : BULK_SESSION_SCHEMA_VERSION
453
- : sourceVersion ?? BULK_SESSION_SCHEMA_VERSION;
454
- const session = normalizeBulkSession(candidate, candidateVersion);
426
+ const session = normalizeBulkSession(candidate);
455
427
  if (!session)
456
428
  continue;
457
429
  const existing = byId.get(session.id);
@@ -623,6 +595,9 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
623
595
  };
624
596
  }
625
597
  export function mergeBulkSessions(localRaw, importedRaw) {
598
+ assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store');
599
+ assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document');
600
+ assertWorkspacePortableVersion(importedRaw, BULK_SESSION_SCHEMA_VERSION, 'Imported Bulk-session document');
626
601
  const imported = record(importedRaw);
627
602
  if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
628
603
  throw new Error('This file is not a WHOISleuth Bulk session export.');
@@ -637,9 +612,8 @@ export function mergeBulkSessions(localRaw, importedRaw) {
637
612
  let added = 0;
638
613
  let updated = 0;
639
614
  let skipped = 0;
640
- const importedVersion = Number(imported.version);
641
615
  for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
642
- const normalized = normalizeBulkSession(candidate, importedVersion);
616
+ const normalized = normalizeBulkSession(candidate);
643
617
  if (!normalized) {
644
618
  skipped += 1;
645
619
  continue;
@@ -19,8 +19,11 @@ function compareText(left, right) {
19
19
  const rightText = boundedText(right);
20
20
  return leftText.localeCompare(rightText, 'en', { numeric: true, sensitivity: 'base' });
21
21
  }
22
- function sortValue(row, key) {
23
- if (key === 'risk' || key === 'opportunity') {
22
+ function sortValue(row, key, comparableRisk) {
23
+ if (key === 'risk') {
24
+ return { missing: !Number.isFinite(comparableRisk), value: Number(comparableRisk ?? 0) };
25
+ }
26
+ if (key === 'opportunity') {
24
27
  return { missing: !Number.isFinite(row[key]), value: Number(row[key] ?? 0) };
25
28
  }
26
29
  if (key === 'confidence') {
@@ -35,10 +38,10 @@ function sortValue(row, key) {
35
38
  export function defaultBulkSortDirection(key) {
36
39
  return key === 'risk' || key === 'opportunity' || key === 'confidence' ? -1 : 1;
37
40
  }
38
- export function sortBulkResults(rows, key, direction) {
41
+ export function sortBulkResults(rows, key, direction, riskValue = null) {
39
42
  return [...rows].sort((left, right) => {
40
- const leftValue = sortValue(left, key);
41
- const rightValue = sortValue(right, key);
43
+ const leftValue = sortValue(left, key, key === 'risk' && riskValue ? riskValue(left) : left.risk);
44
+ const rightValue = sortValue(right, key, key === 'risk' && riskValue ? riskValue(right) : right.risk);
42
45
  // Missing values always remain last instead of jumping to the top when
43
46
  // the requested direction changes.
44
47
  if (leftValue.missing !== rightValue.missing)
@@ -1,23 +1,14 @@
1
1
  // Pure, framework-neutral browser-local campaign model. Campaigns deliberately
2
2
  // reference normalized case domains rather than copying case evidence, notes,
3
3
  // or mutable case ids. The browser wrapper owns persistence and downloads.
4
- import { normalizeDomain } from "./case-model.js";
5
- export const CAMPAIGN_SCHEMA = 'whoisleuth.campaigns';
6
- export const CAMPAIGN_SCHEMA_VERSION = 1;
7
- export const MAX_CAMPAIGNS = 50;
8
- export const MAX_CAMPAIGN_DOMAINS = 50;
9
- export const MAX_CAMPAIGN_NAME_LENGTH = 100;
10
- export const MAX_CAMPAIGN_DESCRIPTION_LENGTH = 1000;
11
- export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
12
- export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
13
- // Cases and watchlists share the same per-origin browser quota. Keep this
14
- // collection smaller than the case store and fail before a browser quota error.
15
- export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
4
+ import { normalizeDomain } from "../cases/case-model.mjs";
5
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
+ import { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
8
+ export { CAMPAIGN_SCHEMA, CAMPAIGN_SCHEMA_VERSION, MAX_CAMPAIGNS, MAX_CAMPAIGN_DESCRIPTION_LENGTH, MAX_CAMPAIGN_DOMAINS, MAX_CAMPAIGN_IMPORT_BYTES, MAX_CAMPAIGN_INPUT_RECORDS, MAX_CAMPAIGN_NAME_LENGTH, MAX_CAMPAIGN_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
16
9
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
17
10
  function plainRecord(value) {
18
- return value && typeof value === 'object' && !Array.isArray(value)
19
- ? value
20
- : null;
11
+ return ordinaryWorkspaceRecord(value, 'Campaign input');
21
12
  }
22
13
  function hashString(value) {
23
14
  let hash = 2166136261 >>> 0;
@@ -36,10 +27,7 @@ function makeId() {
36
27
  return `campaign-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
37
28
  }
38
29
  function isoOrNull(value) {
39
- if (typeof value !== 'string' || value.length > 64 || /[\x00-\x1f\x7f]/.test(value))
40
- return null;
41
- const parsed = Date.parse(value);
42
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
30
+ return normalizeExplicitIsoTimestamp(value);
43
31
  }
44
32
  function normalizeName(value) {
45
33
  return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, MAX_CAMPAIGN_NAME_LENGTH).trim();
@@ -97,10 +85,12 @@ export function campaignStoreVersion(raw) {
97
85
  }
98
86
  /** Recover a deterministic, bounded store from parsed browser data. */
99
87
  export function normalizeCampaignStore(raw) {
100
- const now = new Date().toISOString();
88
+ assertWorkspaceInputGraph(raw, 'Campaign store');
89
+ assertWorkspaceDeclaredVersion(raw, 'Campaign store');
90
+ const fallback = new Date(0).toISOString();
101
91
  const byId = new Map();
102
92
  for (const item of asCampaignList(raw).slice(0, MAX_CAMPAIGN_INPUT_RECORDS)) {
103
- const campaign = normalizeCampaign(item, now);
93
+ const campaign = normalizeCampaign(item, fallback);
104
94
  if (!campaign)
105
95
  continue;
106
96
  const existing = byId.get(campaign.id);
@@ -207,14 +197,22 @@ function mergeCampaign(local, imported) {
207
197
  }
208
198
  /** Non-destructively merge a portable export into local campaigns by id. */
209
199
  export function mergeCampaigns(localRaw, importedRaw) {
200
+ assertWorkspaceInputGraph(localRaw, 'Local campaign store');
201
+ assertWorkspaceInputGraph(importedRaw, 'Imported campaign document');
202
+ assertWorkspacePortableVersion(importedRaw, CAMPAIGN_SCHEMA_VERSION, 'Imported campaign document');
210
203
  const importedEnvelope = plainRecord(importedRaw);
211
- if (importedEnvelope && typeof importedEnvelope.schema === 'string' && importedEnvelope.schema !== CAMPAIGN_SCHEMA) {
204
+ if (!importedEnvelope
205
+ || importedEnvelope.schema !== CAMPAIGN_SCHEMA
206
+ || !Array.isArray(importedEnvelope.campaigns)) {
212
207
  throw new Error('This JSON file is not a WHOISleuth campaign export.');
213
208
  }
214
209
  const version = campaignStoreVersion(importedRaw);
215
210
  if (version !== null && version > CAMPAIGN_SCHEMA_VERSION) {
216
211
  throw new Error(`This campaign file uses newer schema ${version}. Update the app before importing it.`);
217
212
  }
213
+ if (version !== CAMPAIGN_SCHEMA_VERSION) {
214
+ throw new Error(`Expected campaign schema ${CAMPAIGN_SCHEMA_VERSION}.`);
215
+ }
218
216
  const local = normalizeCampaignStore(localRaw).campaigns;
219
217
  const byId = new Map(local.map((campaign) => [campaign.id, campaign]));
220
218
  let added = 0;