@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,18 +1,15 @@
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 = 3;
8
- export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, 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;
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";
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.';
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.';
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.';
16
13
  const CONTROL_RE = /[\x00-\x1f\x7f]/u;
17
14
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
18
15
  const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
@@ -36,9 +33,7 @@ const SOURCE_STATES = new Set([
36
33
  const TRUST_STATES = new Set(['official', 'partner', 'allowlisted']);
37
34
  const COMPARISON_STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
38
35
  function record(value) {
39
- return value && typeof value === 'object' && !Array.isArray(value)
40
- ? value
41
- : null;
36
+ return ordinaryWorkspaceRecord(value, 'Bulk-session input');
42
37
  }
43
38
  function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
44
39
  return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
@@ -46,8 +41,75 @@ function boundedText(value, maximum = MAX_BULK_SESSION_TEXT_LENGTH) {
46
41
  : '';
47
42
  }
48
43
  function timestamp(value, fallback = null) {
49
- const text = boundedText(value, 64);
50
- return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : fallback;
44
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
45
+ }
46
+ function profileContextLimitation(value, fallback) {
47
+ return boundedText(value, MAX_BULK_PROFILE_CONTEXT_LIMITATION_LENGTH) || fallback;
48
+ }
49
+ export function unavailableBulkProfileContext(limitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
50
+ return {
51
+ sourceState: 'unavailable',
52
+ activeProfileId: null,
53
+ profileUpdatedAt: null,
54
+ limitation: profileContextLimitation(limitation, BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION),
55
+ };
56
+ }
57
+ export function bulkProfileContextProvenance(sourceState, profileRaw) {
58
+ if (sourceState !== 'ready')
59
+ return unavailableBulkProfileContext();
60
+ if (profileRaw === null || profileRaw === undefined) {
61
+ return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
62
+ }
63
+ const profile = record(profileRaw);
64
+ const activeProfileId = typeof profile?.id === 'string' && SAFE_ID_RE.test(profile.id) ? profile.id : null;
65
+ const profileUpdatedAt = timestamp(profile?.updatedAt);
66
+ return activeProfileId && profileUpdatedAt
67
+ ? { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' }
68
+ : unavailableBulkProfileContext('The active Brand Profile identity or revision was invalid when this row was evaluated. Profile-derived conclusions and the Risk assessment remain inconclusive.');
69
+ }
70
+ export function normalizeBulkProfileContext(value, fallbackLimitation = BULK_PROFILE_CONTEXT_UNAVAILABLE_LIMITATION) {
71
+ const item = record(value);
72
+ const sourceState = item?.sourceState;
73
+ if (sourceState === 'ready') {
74
+ if (item?.activeProfileId === null && item.profileUpdatedAt === null) {
75
+ return { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' };
76
+ }
77
+ const activeProfileId = typeof item?.activeProfileId === 'string' && SAFE_ID_RE.test(item.activeProfileId)
78
+ ? item.activeProfileId
79
+ : null;
80
+ const profileUpdatedAt = timestamp(item?.profileUpdatedAt);
81
+ if (activeProfileId && profileUpdatedAt) {
82
+ return { sourceState: 'ready', activeProfileId, profileUpdatedAt, limitation: '' };
83
+ }
84
+ }
85
+ if (sourceState === 'mixed') {
86
+ return {
87
+ sourceState: 'mixed',
88
+ activeProfileId: null,
89
+ profileUpdatedAt: null,
90
+ limitation: profileContextLimitation(item?.limitation, 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.'),
91
+ };
92
+ }
93
+ return unavailableBulkProfileContext(profileContextLimitation(item?.limitation, fallbackLimitation));
94
+ }
95
+ function sameProfileContext(left, right) {
96
+ return left.sourceState === right.sourceState
97
+ && left.activeProfileId === right.activeProfileId
98
+ && left.profileUpdatedAt === right.profileUpdatedAt
99
+ && left.limitation === right.limitation;
100
+ }
101
+ export function summarizeBulkProfileContexts(rows) {
102
+ const first = rows[0]?.profileContext;
103
+ if (!first)
104
+ return unavailableBulkProfileContext('This saved Bulk session contains no settled row with profile-context provenance.');
105
+ if (rows.every((row) => sameProfileContext(first, row.profileContext)))
106
+ return { ...first };
107
+ return {
108
+ sourceState: 'mixed',
109
+ activeProfileId: null,
110
+ profileUpdatedAt: null,
111
+ limitation: 'Rows in this saved Bulk session were evaluated under more than one Brand Profile context. Review each row provenance before comparison.',
112
+ };
51
113
  }
52
114
  function nullableBoolean(value) {
53
115
  return typeof value === 'boolean' ? value : null;
@@ -215,7 +277,27 @@ export function normalizeBulkSessionResult(value) {
215
277
  const scanDepth = boundedText(item?.scanDepth, 10);
216
278
  if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
217
279
  return null;
280
+ const rawProfileContext = record(item.profileContext);
281
+ if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
282
+ return null;
283
+ const profileContext = normalizeBulkProfileContext(rawProfileContext);
284
+ if (rawProfileContext.sourceState === 'ready' && profileContext.sourceState !== 'ready')
285
+ return null;
286
+ const profileContextReady = profileContext.sourceState === 'ready';
218
287
  const trusted = boundedText(item.trusted, 20);
288
+ const relationship = normalizeRelationship(item.relationship);
289
+ const readyWithoutActiveProfile = profileContextReady && profileContext.activeProfileId === null;
290
+ const impossibleNoProfileClaim = readyWithoutActiveProfile && (TRUST_STATES.has(trusted)
291
+ || item.faviconMatch === true
292
+ || item.faviconNearMatch === true
293
+ || item.reusesOfficialAssets === true
294
+ || item.idnReferenceMatch === true
295
+ || item.pageBaselineMatch === true
296
+ || item.hasActiveBrandProfile === true
297
+ || relationship.officialAssetHosts.length > 0);
298
+ const profileClaimsUsable = profileContextReady && !impossibleNoProfileClaim;
299
+ if (!profileClaimsUsable || readyWithoutActiveProfile)
300
+ relationship.officialAssetHosts = [];
219
301
  return {
220
302
  domain,
221
303
  status: status,
@@ -223,10 +305,10 @@ export function normalizeBulkSessionResult(value) {
223
305
  confidence: boundedText(item.confidence, 40) || 'unknown',
224
306
  registrar: boundedText(item.registrar, 300) || '—',
225
307
  activity: boundedText(item.activity, 300) || '—',
226
- risk: nullableScore(item.risk),
308
+ risk: profileClaimsUsable ? nullableScore(item.risk) : null,
227
309
  opportunity: nullableScore(item.opportunity),
228
310
  mutationTypes: boundedStrings(item.mutationTypes, 40),
229
- trusted: TRUST_STATES.has(trusted) ? trusted : null,
311
+ trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
230
312
  error: boundedText(item.error),
231
313
  scanDepth: scanDepth,
232
314
  createdDate: boundedText(item.createdDate, 64) || null,
@@ -241,23 +323,24 @@ export function normalizeBulkSessionResult(value) {
241
323
  pageTitle: boundedText(item.pageTitle, 300) || null,
242
324
  faviconHash: nullableHash(item.faviconHash, HASH_64_RE),
243
325
  faviconPHash: nullableHash(item.faviconPHash, PHASH_RE),
244
- faviconMatch: item.faviconMatch === true,
245
- faviconNearMatch: item.faviconNearMatch === true,
246
- reusesOfficialAssets: item.reusesOfficialAssets === true,
326
+ faviconMatch: profileClaimsUsable ? nullableBoolean(item.faviconMatch) : null,
327
+ faviconNearMatch: profileClaimsUsable ? nullableBoolean(item.faviconNearMatch) : null,
328
+ reusesOfficialAssets: profileClaimsUsable ? nullableBoolean(item.reusesOfficialAssets) : null,
247
329
  hasPasswordField: item.hasPasswordField === true,
248
330
  hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
249
331
  phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
250
- idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
251
- pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
252
- hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
253
- riskModelVersion: nullableVersion(item.riskModelVersion),
332
+ idnReferenceMatch: profileClaimsUsable ? nullableBoolean(item.idnReferenceMatch) : null,
333
+ pageBaselineMatch: profileClaimsUsable ? nullableBoolean(item.pageBaselineMatch) : null,
334
+ hasActiveBrandProfile: profileClaimsUsable ? nullableBoolean(item.hasActiveBrandProfile) : null,
335
+ riskModelVersion: profileClaimsUsable ? nullableVersion(item.riskModelVersion) : null,
254
336
  opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
255
- riskFactors: normalizeRiskFactors(item.riskFactors),
337
+ riskFactors: profileClaimsUsable ? normalizeRiskFactors(item.riskFactors) : [],
256
338
  dns: normalizeDns(item.dns),
257
339
  dnssec: boundedText(item.dnssec, 40) || null,
258
340
  comparisonEvidence: normalizeBulkComparisonEvidence(item.comparisonEvidence),
259
- relationship: normalizeRelationship(item.relationship),
341
+ relationship,
260
342
  sourceCoverage: normalizeSourceCoverage(item.sourceCoverage),
343
+ profileContext,
261
344
  };
262
345
  }
263
346
  export function normalizeBulkSession(value) {
@@ -282,18 +365,27 @@ export function normalizeBulkSession(value) {
282
365
  const domains = domainList(item.domains);
283
366
  if (!domains.length)
284
367
  return null;
368
+ if (!Array.isArray(item.results))
369
+ return null;
285
370
  const allowed = new Set(domains);
286
371
  const results = [];
287
372
  const seen = new Set();
288
373
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
289
374
  const result = normalizeBulkSessionResult(candidate);
290
- if (!result || !allowed.has(result.domain) || seen.has(result.domain))
291
- continue;
375
+ if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
376
+ return null;
377
+ }
292
378
  seen.add(result.domain);
293
379
  results.push(result);
294
- if (results.length >= MAX_BULK_SESSION_ROWS)
295
- break;
296
380
  }
381
+ if ((state === 'complete') !== (results.length === domains.length))
382
+ return null;
383
+ const profileContext = summarizeBulkProfileContexts(results);
384
+ if (!record(item.profileContext))
385
+ return null;
386
+ const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
387
+ if (!sameProfileContext(declaredProfileContext, profileContext))
388
+ return null;
297
389
  return {
298
390
  id,
299
391
  name,
@@ -305,6 +397,7 @@ export function normalizeBulkSession(value) {
305
397
  startedAt,
306
398
  updatedAt,
307
399
  completedAt,
400
+ profileContext,
308
401
  };
309
402
  }
310
403
  export function bulkSessionStoreVersion(raw) {
@@ -314,7 +407,13 @@ export function bulkSessionStoreVersion(raw) {
314
407
  : null;
315
408
  }
316
409
  export function normalizeBulkSessionStore(raw) {
410
+ assertWorkspaceInputGraph(raw, 'Bulk-session store');
411
+ assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
317
412
  const value = record(raw);
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
+ }
318
417
  const candidates = Array.isArray(raw)
319
418
  ? raw
320
419
  : value?.schema === BULK_SESSION_SCHEMA
@@ -389,12 +488,22 @@ export function deleteBulkSession(raw, idRaw) {
389
488
  function sourceStateMap(value) {
390
489
  return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
391
490
  }
491
+ function comparableRisk(previous, current) {
492
+ return previous.profileContext.sourceState === 'ready'
493
+ && current.profileContext.sourceState === 'ready'
494
+ && sameProfileContext(previous.profileContext, current.profileContext)
495
+ && previous.risk !== null
496
+ && current.risk !== null
497
+ && previous.riskModelVersion !== null
498
+ && previous.riskModelVersion === current.riskModelVersion;
499
+ }
392
500
  function resultChanges(previous, current) {
393
501
  const changes = [];
394
502
  if (previous.availability !== current.availability)
395
503
  changes.push(`Registration: ${previous.availability} → ${current.availability}`);
396
- if (previous.risk !== current.risk)
504
+ if (comparableRisk(previous, current) && previous.risk !== current.risk) {
397
505
  changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
506
+ }
398
507
  if (previous.registrar !== current.registrar)
399
508
  changes.push(`Registrar: ${previous.registrar} → ${current.registrar}`);
400
509
  if (previous.activity !== current.activity)
@@ -429,6 +538,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
429
538
  const before = new Map(baseline.results.map((result) => [result.domain, result]));
430
539
  const after = new Map(current.results.map((result) => [result.domain, result]));
431
540
  const rows = [];
541
+ let riskIncomparable = false;
432
542
  let added = 0;
433
543
  let removed = 0;
434
544
  let unchanged = 0;
@@ -444,6 +554,8 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
444
554
  rows.push({ domain, changes: ['Absent from the later settled result set.'] });
445
555
  }
446
556
  else {
557
+ if (!comparableRisk(previous, next))
558
+ riskIncomparable = true;
447
559
  const changes = resultChanges(previous, next);
448
560
  if (changes.length)
449
561
  rows.push({ domain, changes });
@@ -463,6 +575,9 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
463
575
  'This compares compact saved observations, not historical provider data or raw source payloads.',
464
576
  'A source-state change may reflect collection availability rather than a change to the domain.',
465
577
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
578
+ ...(riskIncomparable
579
+ ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance and the same versioned Risk model.']
580
+ : []),
466
581
  ],
467
582
  };
468
583
  }
@@ -480,6 +595,9 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
480
595
  };
481
596
  }
482
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');
483
601
  const imported = record(importedRaw);
484
602
  if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
485
603
  throw new Error('This file is not a WHOISleuth Bulk session export.');
@@ -495,20 +613,42 @@ export function mergeBulkSessions(localRaw, importedRaw) {
495
613
  let updated = 0;
496
614
  let skipped = 0;
497
615
  for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
498
- const session = normalizeBulkSession(candidate);
616
+ const normalized = normalizeBulkSession(candidate);
617
+ if (!normalized) {
618
+ skipped += 1;
619
+ continue;
620
+ }
621
+ const importedProfileContext = unavailableBulkProfileContext(BULK_PROFILE_CONTEXT_IMPORTED_LIMITATION);
622
+ const quarantinedResults = normalized.results.map((result) => ({
623
+ ...result,
624
+ risk: null,
625
+ trusted: null,
626
+ faviconMatch: null,
627
+ faviconNearMatch: null,
628
+ reusesOfficialAssets: null,
629
+ idnReferenceMatch: null,
630
+ pageBaselineMatch: null,
631
+ hasActiveBrandProfile: null,
632
+ riskModelVersion: null,
633
+ riskFactors: [],
634
+ relationship: { ...result.relationship, officialAssetHosts: [] },
635
+ profileContext: importedProfileContext,
636
+ }));
637
+ const session = normalizeBulkSession({
638
+ ...normalized,
639
+ results: quarantinedResults,
640
+ profileContext: summarizeBulkProfileContexts(quarantinedResults),
641
+ });
499
642
  if (!session) {
500
643
  skipped += 1;
501
644
  continue;
502
645
  }
503
646
  const existing = sessions.find((item) => item.id === session.id);
504
- if (!existing)
505
- added += 1;
506
- else if (existing.updatedAt !== session.updatedAt)
507
- updated += 1;
508
- else {
647
+ if (existing) {
509
648
  skipped += 1;
510
649
  continue;
511
650
  }
651
+ added += 1;
512
652
  sessions = upsertBulkSession(sessions, session).sessions;
513
653
  }
514
654
  const bounded = enforceBulkSessionStoreBudget({
@@ -1,3 +1,7 @@
1
+ /** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
2
+ export function normalizeBulkPresentationSortKey(key) {
3
+ return key === 'opportunity' ? 'risk' : key;
4
+ }
1
5
  const CONFIDENCE_RANK = {
2
6
  low: 0,
3
7
  medium: 1,
@@ -15,8 +19,11 @@ function compareText(left, right) {
15
19
  const rightText = boundedText(right);
16
20
  return leftText.localeCompare(rightText, 'en', { numeric: true, sensitivity: 'base' });
17
21
  }
18
- function sortValue(row, key) {
19
- 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') {
20
27
  return { missing: !Number.isFinite(row[key]), value: Number(row[key] ?? 0) };
21
28
  }
22
29
  if (key === 'confidence') {
@@ -31,10 +38,10 @@ function sortValue(row, key) {
31
38
  export function defaultBulkSortDirection(key) {
32
39
  return key === 'risk' || key === 'opportunity' || key === 'confidence' ? -1 : 1;
33
40
  }
34
- export function sortBulkResults(rows, key, direction) {
41
+ export function sortBulkResults(rows, key, direction, riskValue = null) {
35
42
  return [...rows].sort((left, right) => {
36
- const leftValue = sortValue(left, key);
37
- 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);
38
45
  // Missing values always remain last instead of jumping to the top when
39
46
  // the requested direction changes.
40
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;
@@ -1,19 +1,10 @@
1
1
  // Pure browser-local custom detection-rule model. Rules are deliberately a
2
2
  // small structured language: field names and operators come from allowlists,
3
3
  // so imported rules cannot execute code or reach outside bounded case evidence.
4
- import { latestCaseEvidence } from "./case-model.js";
5
- export const DETECTION_RULE_SCHEMA = 'whoisleuth.detection-rules';
6
- export const DETECTION_RULE_SCHEMA_VERSION = 1;
7
- export const MAX_DETECTION_RULES = 50;
8
- export const MAX_RULE_CONDITIONS = 8;
9
- export const MAX_RULE_INPUT_RECORDS = 250;
10
- export const MAX_RULE_IMPORT_BYTES = 2 * 1024 * 1024;
11
- export const MAX_RULE_STORE_BYTES = 256 * 1024;
12
- export const MAX_RULE_NAME_LENGTH = 100;
13
- export const MAX_RULE_TAG_LENGTH = 40;
14
- export const MAX_CONDITION_VALUE_LENGTH = 200;
15
- export const MAX_RULE_RISK_DELTA = 25;
16
- export const MAX_CUSTOM_RISK_TOTAL = 50;
4
+ import { latestCaseEvidence } from "../cases/case-model.mjs";
5
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
6
+ import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
7
+ export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
17
8
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
18
9
  const TEXT_CONTROL_RE = /[\x00-\x1f\x7f]/;
19
10
  export const RULE_FIELD_DEFINITIONS = Object.freeze([
@@ -41,9 +32,7 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
41
32
  ]);
42
33
  const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
43
34
  function record(value) {
44
- return value && typeof value === 'object' && !Array.isArray(value)
45
- ? value
46
- : {};
35
+ return ordinaryWorkspaceRecord(value, 'Detection-rule input') ?? {};
47
36
  }
48
37
  export function operatorsForRuleField(field) {
49
38
  const definition = typeof field === 'string' ? FIELD_BY_VALUE.get(field) : undefined;
@@ -145,6 +134,8 @@ export function detectionRuleStoreVersion(raw) {
145
134
  return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
146
135
  }
147
136
  export function normalizeDetectionRuleStore(raw) {
137
+ assertWorkspaceInputGraph(raw, 'Detection-rule store');
138
+ assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
148
139
  const byId = new Map();
149
140
  for (const item of ruleList(raw).slice(0, MAX_RULE_INPUT_RECORDS)) {
150
141
  const rule = normalizeDetectionRule(item);
@@ -282,14 +273,21 @@ export function previewDetectionRule(records, existingRules, rawCandidate) {
282
273
  });
283
274
  }
284
275
  export function mergeDetectionRules(localRaw, importedRaw) {
276
+ assertWorkspaceInputGraph(localRaw, 'Local detection-rule store');
277
+ assertWorkspaceInputGraph(importedRaw, 'Imported detection-rule document');
278
+ assertWorkspacePortableVersion(importedRaw, DETECTION_RULE_SCHEMA_VERSION, 'Imported detection-rule document');
285
279
  const importedRecord = record(importedRaw);
286
- if (typeof importedRecord.schema === 'string' && importedRecord.schema !== DETECTION_RULE_SCHEMA) {
280
+ if (importedRecord.schema !== DETECTION_RULE_SCHEMA
281
+ || !Array.isArray(importedRecord.rules)) {
287
282
  throw new Error('This JSON file is not a WHOISleuth custom-rule export.');
288
283
  }
289
284
  const version = detectionRuleStoreVersion(importedRaw);
290
285
  if (version !== null && version > DETECTION_RULE_SCHEMA_VERSION) {
291
286
  throw new Error(`This custom-rule file uses newer schema ${version}. Update the app before importing it.`);
292
287
  }
288
+ if (version !== DETECTION_RULE_SCHEMA_VERSION) {
289
+ throw new Error(`Expected custom-rule schema ${DETECTION_RULE_SCHEMA_VERSION}.`);
290
+ }
293
291
  const local = normalizeDetectionRuleStore(localRaw).rules;
294
292
  const byId = new Map(local.map((rule) => [rule.id, rule]));
295
293
  const importedList = ruleList(importedRaw);