@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -4,6 +4,8 @@
4
4
  import { normalizeDomain } from "../cases/case-model.mjs";
5
5
  import { normalizeCaaCritical } from "./dns-record-normalization.mjs";
6
6
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
7
+ import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
8
+ import { normalizeRelationshipSourceProjection } from "../comparison/relationship-provenance.mjs";
7
9
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
8
10
  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
11
  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";
@@ -175,7 +177,7 @@ function normalizeSourceCoverage(value) {
175
177
  if (!SOURCE_RE.test(source) || !SOURCE_STATES.has(state) || seen.has(source))
176
178
  continue;
177
179
  seen.add(source);
178
- output.push({ source, state: state });
180
+ output.push({ source, state: state, observedAt: normalizeExplicitIsoTimestamp(item?.observedAt) });
179
181
  if (output.length >= MAX_BULK_SESSION_SOURCES)
180
182
  break;
181
183
  }
@@ -198,8 +200,11 @@ function nullableHash(value, expression) {
198
200
  }
199
201
  function normalizeRelationship(value) {
200
202
  const item = record(value);
203
+ if (item?.version !== undefined && item.version !== 2 && item.version !== RELATIONSHIP_EVIDENCE_VERSION) {
204
+ throw new TypeError('This Bulk relationship projection uses an unsupported version. No source evidence was interpreted.');
205
+ }
201
206
  return {
202
- version: 2,
207
+ version: RELATIONSHIP_EVIDENCE_VERSION,
203
208
  nameservers: boundedStrings(item?.nameservers, 20),
204
209
  ipAddresses: boundedStrings(item?.ipAddresses, 50),
205
210
  trackingIdentifiers: boundedTrackingIdentifiers(item?.trackingIdentifiers),
@@ -207,6 +212,7 @@ function normalizeRelationship(value) {
207
212
  faviconHash: nullableHash(item?.faviconHash, HASH_64_RE),
208
213
  faviconPHash: nullableHash(item?.faviconPHash, PHASH_RE),
209
214
  certificateFingerprint: nullableHash(item?.certificateFingerprint, HASH_64_RE),
215
+ sourceEvidence: item?.version === RELATIONSHIP_EVIDENCE_VERSION ? normalizeRelationshipSourceProjection(item.sourceEvidence) : {},
210
216
  truncated: item?.truncated === true,
211
217
  };
212
218
  }
@@ -270,14 +276,14 @@ export function normalizeBulkComparisonEvidence(value) {
270
276
  },
271
277
  };
272
278
  }
273
- export function normalizeBulkSessionResult(value) {
279
+ export function normalizeBulkSessionResult(value, inheritedProfileContext) {
274
280
  const item = record(value);
275
281
  const domain = normalizeDomain(item?.domain);
276
282
  const status = boundedText(item?.status, 20);
277
283
  const scanDepth = boundedText(item?.scanDepth, 10);
278
284
  if (!item || !domain || !RESULT_STATES.has(status) || !MODES.has(scanDepth))
279
285
  return null;
280
- const rawProfileContext = record(item.profileContext);
286
+ const rawProfileContext = record(Object.hasOwn(item, 'profileContext') ? item.profileContext : inheritedProfileContext);
281
287
  if (!rawProfileContext || !['ready', 'unavailable'].includes(String(rawProfileContext.sourceState)))
282
288
  return null;
283
289
  const profileContext = normalizeBulkProfileContext(rawProfileContext);
@@ -311,6 +317,7 @@ export function normalizeBulkSessionResult(value) {
311
317
  trusted: profileClaimsUsable && !readyWithoutActiveProfile && TRUST_STATES.has(trusted) ? trusted : null,
312
318
  error: boundedText(item.error),
313
319
  scanDepth: scanDepth,
320
+ observedAt: normalizeExplicitIsoTimestamp(item.observedAt),
314
321
  createdDate: boundedText(item.createdDate, 64) || null,
315
322
  expiryDate: boundedText(item.expiryDate, 64) || null,
316
323
  privacyProtected: nullableBoolean(item.privacyProtected),
@@ -343,7 +350,7 @@ export function normalizeBulkSessionResult(value) {
343
350
  profileContext,
344
351
  };
345
352
  }
346
- export function normalizeBulkSession(value) {
353
+ export function normalizeBulkSession(value, sourceStoreVersion) {
347
354
  const item = record(value);
348
355
  const id = boundedText(item?.id, 128);
349
356
  const name = boundedText(item?.name, MAX_BULK_SESSION_NAME_LENGTH);
@@ -367,23 +374,43 @@ export function normalizeBulkSession(value) {
367
374
  return null;
368
375
  if (!Array.isArray(item.results))
369
376
  return null;
377
+ const rawProfileContext = record(item.profileContext);
378
+ if (!rawProfileContext)
379
+ return null;
380
+ const declaredProfileContext = normalizeBulkProfileContext(rawProfileContext);
381
+ // Only an explicitly versioned current document may omit identical row
382
+ // context. Bare runtime rows and public schema 4 must carry their own context;
383
+ // an explicit invalid/null row value never falls back to the session.
384
+ const inheritedProfileContext = sourceStoreVersion === 5
385
+ && declaredProfileContext.sourceState !== 'mixed'
386
+ && rawProfileContext.sourceState === declaredProfileContext.sourceState
387
+ ? declaredProfileContext
388
+ : undefined;
370
389
  const allowed = new Set(domains);
371
390
  const results = [];
372
391
  const seen = new Set();
373
392
  for (const candidate of Array.isArray(item.results) ? item.results.slice(0, MAX_BULK_SESSION_ROWS * 2) : []) {
374
- const result = normalizeBulkSessionResult(candidate);
393
+ const publicLegacyRow = sourceStoreVersion === 4;
394
+ if (publicLegacyRow && record(record(candidate)?.relationship)?.version === RELATIONSHIP_EVIDENCE_VERSION) {
395
+ throw new TypeError('Bulk schema 4 cannot contain newer relationship source evidence; no evidence was interpreted.');
396
+ }
397
+ const result = normalizeBulkSessionResult(candidate, inheritedProfileContext);
375
398
  if (!result || !allowed.has(result.domain) || seen.has(result.domain)) {
376
399
  return null;
377
400
  }
401
+ // Public schema 4 never recorded a row observation time or qualified
402
+ // relationship sources. Ignore added fields rather than backdating them.
403
+ if (publicLegacyRow) {
404
+ result.observedAt = null;
405
+ result.relationship.sourceEvidence = {};
406
+ result.sourceCoverage = result.sourceCoverage.map((source) => ({ ...source, observedAt: null }));
407
+ }
378
408
  seen.add(result.domain);
379
409
  results.push(result);
380
410
  }
381
411
  if ((state === 'complete') !== (results.length === domains.length))
382
412
  return null;
383
413
  const profileContext = summarizeBulkProfileContexts(results);
384
- if (!record(item.profileContext))
385
- return null;
386
- const declaredProfileContext = normalizeBulkProfileContext(item.profileContext);
387
414
  if (!sameProfileContext(declaredProfileContext, profileContext))
388
415
  return null;
389
416
  return {
@@ -407,7 +434,9 @@ export function bulkSessionStoreVersion(raw) {
407
434
  : null;
408
435
  }
409
436
  export function normalizeBulkSessionStore(raw) {
410
- assertWorkspaceInputGraph(raw, 'Bulk-session store');
437
+ // Preparation may combine an existing store and one incoming store before
438
+ // whole-session eviction. The persisted document still has its 4 MiB cap.
439
+ assertWorkspaceInputGraph(raw, 'Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
411
440
  assertWorkspaceDeclaredVersion(raw, 'Bulk-session store');
412
441
  const value = record(raw);
413
442
  if (value?.schema === BULK_SESSION_SCHEMA
@@ -423,7 +452,7 @@ export function normalizeBulkSessionStore(raw) {
423
452
  : [];
424
453
  const byId = new Map();
425
454
  for (const candidate of candidates.slice(0, MAX_BULK_SESSIONS * 4)) {
426
- const session = normalizeBulkSession(candidate);
455
+ const session = normalizeBulkSession(candidate, bulkSessionStoreVersion(raw) ?? undefined);
427
456
  if (!session)
428
457
  continue;
429
458
  const existing = byId.get(session.id);
@@ -438,8 +467,21 @@ export function normalizeBulkSessionStore(raw) {
438
467
  .slice(0, MAX_BULK_SESSIONS),
439
468
  };
440
469
  }
470
+ /** Current wire representation of an already-normalized session. */
471
+ export function bulkSessionStorageValue(session) {
472
+ return {
473
+ ...session,
474
+ results: session.results.map(({ profileContext, ...row }) => sameProfileContext(profileContext, session.profileContext)
475
+ ? row
476
+ : { ...row, profileContext }),
477
+ };
478
+ }
479
+ /** Current wire bytes for sessions already admitted by the collection owner. */
480
+ export function serializeNormalizedBulkSessions(sessions) {
481
+ return JSON.stringify({ schema: BULK_SESSION_SCHEMA, version: BULK_SESSION_SCHEMA_VERSION, sessions: sessions.map(bulkSessionStorageValue) });
482
+ }
441
483
  export function serializeBulkSessionStore(raw) {
442
- return JSON.stringify(normalizeBulkSessionStore(raw));
484
+ return serializeNormalizedBulkSessions(normalizeBulkSessionStore(raw).sessions);
443
485
  }
444
486
  function byteLength(value) {
445
487
  return new TextEncoder().encode(value).byteLength;
@@ -447,11 +489,16 @@ function byteLength(value) {
447
489
  export function enforceBulkSessionStoreBudget(raw) {
448
490
  const store = normalizeBulkSessionStore(raw);
449
491
  let pruned = 0;
450
- while (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
492
+ const sessionBytes = store.sessions.map((session) => byteLength(JSON.stringify(bulkSessionStorageValue(session))));
493
+ let bytes = byteLength(JSON.stringify({ ...store, sessions: [] }))
494
+ + sessionBytes.reduce((total, size) => total + size, 0)
495
+ + Math.max(0, sessionBytes.length - 1);
496
+ while (bytes > MAX_BULK_SESSION_STORE_BYTES && store.sessions.length > 1) {
497
+ bytes -= sessionBytes.pop() + 1;
451
498
  store.sessions.pop();
452
499
  pruned += 1;
453
500
  }
454
- if (byteLength(JSON.stringify(store)) > MAX_BULK_SESSION_STORE_BYTES) {
501
+ if (bytes > MAX_BULK_SESSION_STORE_BYTES) {
455
502
  throw new Error('This Bulk session exceeds the 4 MiB browser-local session limit.');
456
503
  }
457
504
  return { store, pruned };
@@ -474,9 +521,29 @@ export function upsertBulkSession(raw, sessionRaw) {
474
521
  sessions: bounded.store.sessions,
475
522
  session,
476
523
  added: index < 0,
477
- pruned: bounded.pruned,
524
+ pruned: Math.max(0, next.length - bounded.store.sessions.length),
478
525
  };
479
526
  }
527
+ /** Ephemeral retention preview, never part of the stored or exported format. */
528
+ export function prepareBulkSessionSave(raw, sessionRaw) {
529
+ const current = normalizeBulkSessionStore(raw).sessions;
530
+ const result = upsertBulkSession(current, sessionRaw);
531
+ if (!result.sessions.some((session) => session.id === result.session.id)) {
532
+ throw new Error('This Bulk session cannot fit alongside the saved sessions. Export or remove saved sessions before trying again. Nothing was changed.');
533
+ }
534
+ const retained = new Set(result.sessions.map((session) => session.id));
535
+ const removed = current.filter((session) => !retained.has(session.id));
536
+ return {
537
+ ...result,
538
+ current,
539
+ removed,
540
+ removedBytes: removed.reduce((total, session) => total + byteLength(JSON.stringify(bulkSessionStorageValue(session))), 0),
541
+ };
542
+ }
543
+ /** Confirmation applies to the exact records displayed, not just their IDs. */
544
+ export function bulkSessionSavePreviewIsCurrent(raw, preview) {
545
+ return serializeBulkSessionStore(raw) === serializeNormalizedBulkSessions(preview.current);
546
+ }
480
547
  export function deleteBulkSession(raw, idRaw) {
481
548
  const id = boundedText(idRaw, 128);
482
549
  return normalizeBulkSessionStore({
@@ -488,10 +555,11 @@ export function deleteBulkSession(raw, idRaw) {
488
555
  function sourceStateMap(value) {
489
556
  return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
490
557
  }
491
- function comparableRisk(previous, current) {
558
+ export function areBulkRiskScoresComparable(previous, current) {
492
559
  return previous.profileContext.sourceState === 'ready'
493
560
  && current.profileContext.sourceState === 'ready'
494
561
  && sameProfileContext(previous.profileContext, current.profileContext)
562
+ && previous.scanDepth === current.scanDepth
495
563
  && previous.risk !== null
496
564
  && current.risk !== null
497
565
  && previous.riskModelVersion !== null
@@ -501,7 +569,7 @@ function resultChanges(previous, current) {
501
569
  const changes = [];
502
570
  if (previous.availability !== current.availability)
503
571
  changes.push(`Registration: ${previous.availability} → ${current.availability}`);
504
- if (comparableRisk(previous, current) && previous.risk !== current.risk) {
572
+ if (areBulkRiskScoresComparable(previous, current) && previous.risk !== current.risk) {
505
573
  changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
506
574
  }
507
575
  if (previous.registrar !== current.registrar)
@@ -554,7 +622,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
554
622
  rows.push({ domain, changes: ['Absent from the later settled result set.'] });
555
623
  }
556
624
  else {
557
- if (!comparableRisk(previous, next))
625
+ if (!areBulkRiskScoresComparable(previous, next))
558
626
  riskIncomparable = true;
559
627
  const changes = resultChanges(previous, next);
560
628
  if (changes.length)
@@ -576,7 +644,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
576
644
  'A source-state change may reflect collection availability rather than a change to the domain.',
577
645
  'A missing row means it was not completed in that saved session; it does not establish domain removal.',
578
646
  ...(riskIncomparable
579
- ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance and the same versioned Risk model.']
647
+ ? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
580
648
  : []),
581
649
  ],
582
650
  };
@@ -587,7 +655,7 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
587
655
  schema: BULK_SESSION_SCHEMA,
588
656
  version: BULK_SESSION_SCHEMA_VERSION,
589
657
  generatedAt: timestamp(generatedAt, new Date().toISOString()),
590
- sessions: store.sessions,
658
+ sessions: store.sessions.map(bulkSessionStorageValue),
591
659
  limitations: [
592
660
  'Compact Bulk results and source states only; raw source payloads and contact records are excluded.',
593
661
  'Import is non-destructive and does not resume network collection automatically.',
@@ -595,8 +663,8 @@ export function buildBulkSessionExport(sessions, generatedAt = new Date().toISOS
595
663
  };
596
664
  }
597
665
  export function mergeBulkSessions(localRaw, importedRaw) {
598
- assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store');
599
- assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document');
666
+ assertWorkspaceInputGraph(localRaw, 'Local Bulk-session store', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
667
+ assertWorkspaceInputGraph(importedRaw, 'Imported Bulk-session document', { maximumBytes: MAX_BULK_SESSION_STORE_BYTES * 2 });
600
668
  assertWorkspacePortableVersion(importedRaw, BULK_SESSION_SCHEMA_VERSION, 'Imported Bulk-session document');
601
669
  const imported = record(importedRaw);
602
670
  if (!imported || imported.schema !== BULK_SESSION_SCHEMA || !Array.isArray(imported.sessions)) {
@@ -613,7 +681,7 @@ export function mergeBulkSessions(localRaw, importedRaw) {
613
681
  let updated = 0;
614
682
  let skipped = 0;
615
683
  for (const candidate of imported.sessions.slice(0, MAX_BULK_SESSIONS * 4)) {
616
- const normalized = normalizeBulkSession(candidate);
684
+ const normalized = normalizeBulkSession(candidate, Number(imported.version));
617
685
  if (!normalized) {
618
686
  skipped += 1;
619
687
  continue;
@@ -648,8 +716,16 @@ export function mergeBulkSessions(localRaw, importedRaw) {
648
716
  skipped += 1;
649
717
  continue;
650
718
  }
719
+ // An import is additive: count and byte pressure must not silently remove
720
+ // a local session (or an earlier admitted import). The preview reports
721
+ // the skipped candidate and the analyst can export/remove data explicitly.
722
+ const merged = upsertBulkSession(sessions, session);
723
+ if (merged.pruned > 0 || !merged.sessions.some((item) => item.id === session.id)) {
724
+ skipped += 1;
725
+ continue;
726
+ }
651
727
  added += 1;
652
- sessions = upsertBulkSession(sessions, session).sessions;
728
+ sessions = merged.sessions;
653
729
  }
654
730
  const bounded = enforceBulkSessionStoreBudget({
655
731
  schema: BULK_SESSION_SCHEMA,
@@ -1,3 +1,4 @@
1
+ export const BULK_SORT_KEYS = ['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'];
1
2
  /** Opportunity remains a retained data/export field, but is no longer a Bulk presentation sort. */
2
3
  export function normalizeBulkPresentationSortKey(key) {
3
4
  return key === 'opportunity' ? 'risk' : key;
@@ -0,0 +1,102 @@
1
+ import { CASE_VIEWS_SCHEMA, CASE_VIEWS_VERSION, CASE_VIEW_SORTS, MAX_CASE_VIEWS, MAX_CASE_VIEWS_BYTES, MAX_CASE_VIEW_NAME_LENGTH, MAX_CASE_VIEW_SEARCH_LENGTH, } from "../contracts/case-views-contract.mjs";
2
+ import { isValidDisposition, isValidStatus } from "../cases/case-record-decisions.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { assertWorkspaceInputGraph, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
5
+ const encoder = new TextEncoder();
6
+ const control = /[\u0000-\u001f\u007f]/u;
7
+ function object(raw, keys, label) {
8
+ const value = ordinaryWorkspaceRecord(raw, label);
9
+ if (!value || Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key)))
10
+ throw new Error(`${label} has missing or unsupported fields.`);
11
+ return value;
12
+ }
13
+ function text(raw, maximum, label) {
14
+ if (typeof raw !== 'string' || raw.length > maximum || control.test(raw))
15
+ throw new Error(`${label} is invalid or exceeds ${maximum} characters.`);
16
+ return raw;
17
+ }
18
+ function time(raw) {
19
+ const value = normalizeExplicitIsoTimestamp(raw);
20
+ if (!value)
21
+ throw new Error('A saved Case view has an invalid timestamp.');
22
+ return value;
23
+ }
24
+ export function normalizeCaseViewFilters(raw) {
25
+ const value = object(raw, ['status', 'disposition', 'search', 'sort'], 'Case filters');
26
+ if (value.status !== '' && !isValidStatus(value.status))
27
+ throw new Error('Unsupported Case status filter.');
28
+ if (value.disposition !== '' && !isValidDisposition(value.disposition))
29
+ throw new Error('Unsupported Case disposition filter.');
30
+ const sort = CASE_VIEW_SORTS.find(option => option.value === value.sort)?.value;
31
+ if (!sort)
32
+ throw new Error('Unsupported Case sort order.');
33
+ return { status: value.status, disposition: value.disposition, search: text(value.search, MAX_CASE_VIEW_SEARCH_LENGTH, 'Case search'), sort };
34
+ }
35
+ export function emptyCaseViewsStore() {
36
+ return { schema: CASE_VIEWS_SCHEMA, version: CASE_VIEWS_VERSION, views: [] };
37
+ }
38
+ export function caseViewsStoreVersion(raw) {
39
+ const value = ordinaryWorkspaceRecord(raw, 'Case views');
40
+ return value?.schema === CASE_VIEWS_SCHEMA && Number.isSafeInteger(value.version) ? value.version : null;
41
+ }
42
+ /** Strict, lossless admission: invalid views and capacity excess never prune saved work. */
43
+ export function normalizeCaseViewsStore(raw) {
44
+ if (raw === null || raw === undefined)
45
+ return emptyCaseViewsStore();
46
+ assertWorkspaceInputGraph(raw, 'Saved Case views', { maximumBytes: MAX_CASE_VIEWS_BYTES });
47
+ const store = object(raw, ['schema', 'version', 'views'], 'Case views');
48
+ if (store.schema !== CASE_VIEWS_SCHEMA || store.version !== CASE_VIEWS_VERSION)
49
+ throw new Error('Unsupported saved Case view format. No views were changed.');
50
+ if (!Array.isArray(store.views) || store.views.length > MAX_CASE_VIEWS)
51
+ throw new Error(`Keep at most ${MAX_CASE_VIEWS} saved Case views. Delete unwanted views explicitly before adding more.`);
52
+ const ids = new Set();
53
+ const views = [];
54
+ let bytes = encoder.encode(JSON.stringify(emptyCaseViewsStore())).byteLength;
55
+ for (const candidate of store.views) {
56
+ const value = object(candidate, ['id', 'name', 'filters', 'createdAt', 'updatedAt'], 'Saved Case view');
57
+ if (typeof value.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value.id) || ids.has(value.id))
58
+ throw new Error('Invalid or duplicate saved Case view identity.');
59
+ ids.add(value.id);
60
+ const name = text(value.name, MAX_CASE_VIEW_NAME_LENGTH, 'Case view name').trim();
61
+ if (!name)
62
+ throw new Error('Name this Case view.');
63
+ const createdAt = time(value.createdAt);
64
+ const updatedAt = time(value.updatedAt);
65
+ if (Date.parse(updatedAt) < Date.parse(createdAt))
66
+ throw new Error('A saved Case view update precedes its creation.');
67
+ const view = { id: value.id, name, filters: normalizeCaseViewFilters(value.filters), createdAt, updatedAt };
68
+ bytes += encoder.encode(JSON.stringify(view)).byteLength + (views.length ? 1 : 0);
69
+ if (bytes > MAX_CASE_VIEWS_BYTES)
70
+ throw new Error('Saved Case views exceed their storage limit. Existing views were not removed.');
71
+ views.push(view);
72
+ }
73
+ return { ...emptyCaseViewsStore(), views };
74
+ }
75
+ export function serializeCaseViewsStore(raw) {
76
+ return JSON.stringify(normalizeCaseViewsStore(raw));
77
+ }
78
+ export function serialiseCaseViewsJson(raw) {
79
+ return `${JSON.stringify(normalizeCaseViewsStore(raw), null, 2)}\n`;
80
+ }
81
+ export function mergeCaseViews(local, incoming) {
82
+ const current = normalizeCaseViewsStore(local);
83
+ const imported = normalizeCaseViewsStore(incoming);
84
+ const views = new Map(current.views.map(view => [view.id, view]));
85
+ let added = 0, updated = 0, skipped = 0;
86
+ for (const view of imported.views) {
87
+ const existing = views.get(view.id);
88
+ if (!existing) {
89
+ views.set(view.id, view);
90
+ added += 1;
91
+ }
92
+ else if (JSON.stringify(existing) === JSON.stringify(view))
93
+ continue;
94
+ else if (Date.parse(view.updatedAt) > Date.parse(existing.updatedAt) && view.createdAt === existing.createdAt) {
95
+ views.set(view.id, view);
96
+ updated += 1;
97
+ }
98
+ else
99
+ skipped += 1;
100
+ }
101
+ return { store: normalizeCaseViewsStore({ ...current, views: [...views.values()] }), added, updated, skipped };
102
+ }
@@ -2,6 +2,7 @@
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
4
  import { latestCaseEvidence } from "../cases/case-model.mjs";
5
+ import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
5
6
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
6
7
  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
8
  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";
@@ -26,8 +27,8 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
26
27
  { value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
27
28
  { value: 'nameservers', label: 'Nameserver', kind: 'list' },
28
29
  { value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
29
- { value: 'status', label: 'Case status', kind: 'enum', values: ['new', 'investigating', 'monitoring', 'escalated', 'closed'] },
30
- { value: 'disposition', label: 'Case disposition', kind: 'enum', values: ['unreviewed', 'benign', 'suspicious', 'confirmed_abuse', 'false_positive'] },
30
+ { value: 'status', label: 'Case status', kind: 'enum', values: CASE_STATUSES.map((item) => item.value) },
31
+ { value: 'disposition', label: 'Case disposition', kind: 'enum', values: CASE_DISPOSITIONS.map((item) => item.value) },
31
32
  { value: 'tags', label: 'Case tag', kind: 'list' },
32
33
  ]);
33
34
  const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
@@ -103,13 +104,14 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
103
104
  return null;
104
105
  const conditions = [];
105
106
  const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
106
- for (const item of rawConditions.slice(0, MAX_RULE_CONDITIONS)) {
107
- const condition = normalizeRuleCondition(item);
108
- if (condition)
109
- conditions.push(condition);
110
- }
111
- if (!conditions.length)
107
+ if (!rawConditions.length || rawConditions.length > MAX_RULE_CONDITIONS)
112
108
  return null;
109
+ for (const rawCondition of rawConditions) {
110
+ const condition = normalizeRuleCondition(rawCondition);
111
+ if (!condition)
112
+ return null;
113
+ conditions.push(condition);
114
+ }
113
115
  const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
114
116
  return {
115
117
  id: safeId(item.id) || (generateId ? makeId() : null),
@@ -121,6 +123,30 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
121
123
  tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
122
124
  };
123
125
  }
126
+ function validateDetectionRule(raw, { generateId = false } = {}) {
127
+ const item = record(raw);
128
+ if (!Array.isArray(item.conditions) || !item.conditions.length) {
129
+ throw new Error('A custom rule needs at least one condition.');
130
+ }
131
+ if (item.conditions.length > MAX_RULE_CONDITIONS) {
132
+ throw new Error(`Custom rules are limited to ${MAX_RULE_CONDITIONS} conditions.`);
133
+ }
134
+ for (const [index, condition] of item.conditions.entries()) {
135
+ if (!normalizeRuleCondition(condition)) {
136
+ throw new Error(`Condition ${index + 1} is invalid or outside the allowed bounds.`);
137
+ }
138
+ }
139
+ if (item.match !== undefined && item.match !== 'all' && item.match !== 'any') {
140
+ throw new Error('Choose whether all or any conditions must match.');
141
+ }
142
+ if (item.riskDelta !== undefined && normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA) === null) {
143
+ throw new Error(`Custom contribution must be a whole number from 0 to ${MAX_RULE_RISK_DELTA}.`);
144
+ }
145
+ const normalized = normalizeDetectionRule(item, { generateId });
146
+ if (!normalized)
147
+ throw new Error('Enter a rule name and valid conditions.');
148
+ return normalized;
149
+ }
124
150
  function ruleList(raw) {
125
151
  if (Array.isArray(raw))
126
152
  return raw;
@@ -133,23 +159,32 @@ export function detectionRuleStoreVersion(raw) {
133
159
  const item = record(raw);
134
160
  return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
135
161
  }
136
- export function normalizeDetectionRuleStore(raw) {
162
+ export function recoverDetectionRuleStore(raw) {
137
163
  assertWorkspaceInputGraph(raw, 'Detection-rule store');
138
164
  assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
139
165
  const byId = new Map();
140
- for (const item of ruleList(raw).slice(0, MAX_RULE_INPUT_RECORDS)) {
166
+ const source = ruleList(raw);
167
+ let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
168
+ for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
141
169
  const rule = normalizeDetectionRule(item);
142
- if (!rule?.id || byId.has(rule.id))
170
+ if (!rule?.id || byId.has(rule.id)) {
171
+ rejected += 1;
143
172
  continue;
173
+ }
174
+ if (byId.size >= MAX_DETECTION_RULES) {
175
+ rejected += 1;
176
+ continue;
177
+ }
144
178
  byId.set(rule.id, { ...rule, id: rule.id });
145
- if (byId.size >= MAX_DETECTION_RULES)
146
- break;
147
179
  }
148
- return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
180
+ return { store: { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] }, rejected };
181
+ }
182
+ export function normalizeDetectionRuleStore(raw) {
183
+ return recoverDetectionRuleStore(raw).store;
149
184
  }
150
185
  export function createDetectionRule(rules, input) {
151
- const normalized = normalizeDetectionRule(input, { generateId: true });
152
- if (!normalized?.id)
186
+ const normalized = validateDetectionRule(input, { generateId: true });
187
+ if (!normalized.id)
153
188
  throw new Error('Enter a rule name and one valid condition.');
154
189
  const existing = normalizeDetectionRuleStore(rules).rules;
155
190
  if (existing.length >= MAX_DETECTION_RULES)
@@ -162,8 +197,8 @@ export function updateDetectionRule(rules, id, patch) {
162
197
  const current = normalizedRules.find((rule) => rule.id === id);
163
198
  if (!current)
164
199
  throw new Error('That custom rule no longer exists.');
165
- const updated = normalizeDetectionRule({ ...current, ...record(patch), id });
166
- if (!updated?.id)
200
+ const updated = validateDetectionRule({ ...current, ...record(patch), id });
201
+ if (!updated.id)
167
202
  throw new Error('A custom rule needs a name and at least one valid condition.');
168
203
  return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
169
204
  }
@@ -245,8 +280,14 @@ function ruleSignature(rule) {
245
280
  });
246
281
  }
247
282
  export function previewDetectionRule(records, existingRules, rawCandidate) {
248
- const normalized = normalizeDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
249
- if (!normalized?.id)
283
+ let normalized;
284
+ try {
285
+ normalized = validateDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ if (!normalized.id)
250
291
  return null;
251
292
  const candidate = { ...normalized, id: normalized.id };
252
293
  const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
@@ -295,8 +336,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
295
336
  let updated = 0;
296
337
  let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
297
338
  for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
298
- const rule = normalizeDetectionRule(item);
299
- if (!rule?.id) {
339
+ let rule;
340
+ try {
341
+ rule = validateDetectionRule(item);
342
+ }
343
+ catch {
344
+ skipped++;
345
+ continue;
346
+ }
347
+ if (!rule.id) {
300
348
  skipped++;
301
349
  continue;
302
350
  }
@@ -1,4 +1,5 @@
1
1
  import { MAX_WORKSPACE_INPUT_ARRAY_LENGTH, MAX_WORKSPACE_INPUT_GRAPH_DEPTH, MAX_WORKSPACE_INPUT_GRAPH_NODES, MAX_WORKSPACE_INPUT_OBJECT_KEYS, MAX_WORKSPACE_INPUT_STRING_CODE_UNITS, } from "../contracts/workspace-portability.mjs";
2
+ import { boundedJsonLimitsForBytes } from "../../lib/bounded-json.mjs";
2
3
  function inputError(label, detail) {
3
4
  return new TypeError(`${label} must be bounded ordinary JSON data; ${detail}.`);
4
5
  }
@@ -27,9 +28,7 @@ function ownDataDescriptors(value, label) {
27
28
  return [key, descriptor];
28
29
  });
29
30
  }
30
- export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
31
- if (!value || typeof value !== 'object' || Array.isArray(value))
32
- return null;
31
+ function ordinaryRecordDescriptors(value, label) {
33
32
  let prototype;
34
33
  try {
35
34
  prototype = Object.getPrototypeOf(value);
@@ -47,19 +46,33 @@ export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
47
46
  if (descriptors.some(([, descriptor]) => !descriptor.enumerable)) {
48
47
  throw inputError(label, 'non-enumerable object fields are not supported');
49
48
  }
49
+ return descriptors;
50
+ }
51
+ export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
52
+ if (!value || typeof value !== 'object' || Array.isArray(value))
53
+ return null;
54
+ ordinaryRecordDescriptors(value, label);
50
55
  return value;
51
56
  }
52
- export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
57
+ export function assertWorkspaceInputGraph(value, label = 'Workspace input', options = {}) {
58
+ const limits = options.maximumBytes === undefined ? null : boundedJsonLimitsForBytes(options.maximumBytes);
59
+ const maximumStringCodeUnits = limits?.maximumStringCodeUnits ?? MAX_WORKSPACE_INPUT_STRING_CODE_UNITS;
53
60
  const pending = [{ value, depth: 0, label }];
54
61
  const seen = new WeakSet();
55
62
  let nodes = 0;
63
+ let values = 0;
64
+ let keys = 0;
56
65
  let stringCodeUnits = 0;
57
66
  while (pending.length) {
58
67
  const current = pending.pop();
59
68
  const candidate = current.value;
69
+ values += 1;
70
+ if (limits && values > limits.maximumValues) {
71
+ throw inputError(label, 'the graph exceeds the byte-budgeted value ceiling');
72
+ }
60
73
  if (typeof candidate === 'string') {
61
74
  stringCodeUnits += candidate.length;
62
- if (stringCodeUnits > MAX_WORKSPACE_INPUT_STRING_CODE_UNITS) {
75
+ if (stringCodeUnits > maximumStringCodeUnits) {
63
76
  throw inputError(label, 'aggregate text exceeds the string ceiling');
64
77
  }
65
78
  continue;
@@ -76,7 +89,7 @@ export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
76
89
  throw inputError(label, 'the nesting depth exceeds the graph ceiling');
77
90
  }
78
91
  nodes += 1;
79
- if (nodes > MAX_WORKSPACE_INPUT_GRAPH_NODES) {
92
+ if (!limits && nodes > MAX_WORKSPACE_INPUT_GRAPH_NODES) {
80
93
  throw inputError(label, 'the graph exceeds the node ceiling');
81
94
  }
82
95
  if (seen.has(candidate))
@@ -118,8 +131,16 @@ export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
118
131
  }
119
132
  continue;
120
133
  }
121
- const record = ordinaryWorkspaceRecord(candidate, current.label);
122
- const descriptors = ownDataDescriptors(record, current.label);
134
+ const descriptors = ordinaryRecordDescriptors(candidate, current.label);
135
+ if (limits) {
136
+ keys += descriptors.length;
137
+ if (keys > limits.maximumKeys)
138
+ throw inputError(label, 'the graph exceeds the byte-budgeted key ceiling');
139
+ stringCodeUnits += descriptors.reduce((total, [key]) => total + key.length, 0);
140
+ if (stringCodeUnits > maximumStringCodeUnits) {
141
+ throw inputError(label, 'aggregate text exceeds the string ceiling');
142
+ }
143
+ }
123
144
  for (const [key, descriptor] of descriptors) {
124
145
  pending.push({
125
146
  value: descriptor.value,