@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -2,16 +2,12 @@
2
2
  // the watchlist entry for rescanning, while history stores compact changes
3
3
  // against a last-known baseline so browser-local storage does not grow by one complete
4
4
  // result set on every check.
5
- import { explainRiskScore, normalizeRiskModelVersion } from "./scoring.js";
6
- import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "./http-summary.js";
7
- import { normalizeDomain } from "./case-model.js";
8
- export const MAX_WATCHLIST_HISTORY_EVENTS = 12;
9
- export const MAX_WATCHLIST_CHANGES_PER_EVENT = 500;
10
- export const MAX_WATCHLIST_DOMAINS = 2000;
11
- export const MAX_WATCHLIST_INPUT_RECORDS = MAX_WATCHLIST_DOMAINS * 2;
12
- export const MAX_WATCHLIST_NAMESERVERS = 12;
13
- export const MAX_WATCHLIST_MUTATION_TYPES = 30;
14
- export const MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS = MAX_WATCHLIST_DOMAINS;
5
+ import { explainRiskScore, normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
6
+ import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
7
+ import { normalizeDomain } from "../cases/case-model.mjs";
8
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
9
+ import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
10
+ export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
15
11
  const MAX_TEXT_LENGTH = 300;
16
12
  const MAX_TITLE_LENGTH = 200;
17
13
  const MAX_MUTATION_TYPE_LENGTH = 60;
@@ -149,10 +145,7 @@ function boundedText(value, maximum = MAX_TEXT_LENGTH, allowNull = true) {
149
145
  return normalized || (allowNull ? null : '');
150
146
  }
151
147
  function isoTimestamp(value, fallback = new Date(0).toISOString()) {
152
- if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
153
- return fallback;
154
- const parsed = Date.parse(value);
155
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
148
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
156
149
  }
157
150
  function boundedInteger(value, maximum, fallback = 0) {
158
151
  return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum
@@ -487,7 +480,7 @@ export function normalizeWatchlistEntry(entry) {
487
480
  : [];
488
481
  const rawMode = event.mode;
489
482
  return {
490
- checkedAt: isoTimestamp(event.checkedAt),
483
+ checkedAt: isoTimestamp(event.checkedAt, new Date(0).toISOString()),
491
484
  mode: typeof rawMode === 'string' && ['fast', 'deep', 'saved'].includes(rawMode)
492
485
  ? rawMode
493
486
  : 'saved',
@@ -500,7 +493,7 @@ export function normalizeWatchlistEntry(entry) {
500
493
  }).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
501
494
  : [];
502
495
  const normalized = {
503
- updatedAt: isoTimestamp(input.updatedAt),
496
+ updatedAt: isoTimestamp(input.updatedAt, new Date(0).toISOString()),
504
497
  results,
505
498
  baseline,
506
499
  history,
@@ -1,22 +1,15 @@
1
1
  // Pure browser-local watchlist collection model. Watchlist history owns the
2
2
  // evidence shape and diff semantics; this module owns collection names, schema
3
3
  // migration, import merging, and exact serialized-byte accounting.
4
- import { MAX_WATCHLIST_DOMAINS, normalizeWatchlistEntry } from "./watchlist-history.js";
5
- export const WATCHLIST_SCHEMA_VERSION = 2;
6
- export const WATCHLIST_SCHEMA = 'whoisleuth.watchlists';
7
- export const MAX_WATCHLISTS = 100;
8
- export const MAX_WATCHLIST_INPUTS = MAX_WATCHLISTS * 4;
9
- export const MAX_WATCHLIST_NAME_LENGTH = 100;
10
- // Cases and other analyst stores share the same origin quota. A two-megabyte
11
- // ceiling accommodates practical Bulk results while failing before the browser
12
- // turns storage pressure into an opaque quota exception.
13
- export const MAX_WATCHLIST_STORE_BYTES = 2 * 1024 * 1024;
4
+ import { MAX_WATCHLIST_DOMAINS, normalizeWatchlistEntry } from "./watchlist-history.mjs";
5
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
7
+ import { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
8
+ export { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
14
9
  const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
15
10
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
16
11
  function plainRecord(value) {
17
- return value && typeof value === 'object' && !Array.isArray(value)
18
- ? value
19
- : null;
12
+ return ordinaryWorkspaceRecord(value, 'Watchlist input');
20
13
  }
21
14
  function isEnvelope(value) {
22
15
  return Boolean(value && value.schema === WATCHLIST_SCHEMA && plainRecord(value.watchlists));
@@ -37,14 +30,23 @@ function watchlistMap(raw) {
37
30
  }
38
31
  export function watchlistStoreVersion(raw) {
39
32
  const value = plainRecord(raw);
40
- if (!value || !isEnvelope(value))
41
- return value ? 1 : null;
33
+ if (!value)
34
+ return null;
35
+ if (!isEnvelope(value))
36
+ return WATCHLIST_SCHEMA_VERSION;
42
37
  return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
43
38
  }
44
39
  function defineEntry(target, name, entry) {
45
40
  Object.defineProperty(target, name, { value: entry, writable: true, enumerable: true, configurable: true });
46
41
  }
47
42
  export function normalizeWatchlistStore(raw) {
43
+ assertWorkspaceInputGraph(raw, 'Watchlist store');
44
+ const root = plainRecord(raw);
45
+ if (root?.schema === WATCHLIST_SCHEMA)
46
+ assertWorkspaceDeclaredVersion(raw, 'Watchlist store');
47
+ if (root?.schema === WATCHLIST_SCHEMA && watchlistStoreVersion(root) !== WATCHLIST_SCHEMA_VERSION) {
48
+ throw new Error(`Watchlist schema ${String(root.version)} is unsupported; no data was changed.`);
49
+ }
48
50
  const source = watchlistMap(raw);
49
51
  const watchlists = {};
50
52
  if (!source)
@@ -83,6 +85,9 @@ function validateImportShape(raw) {
83
85
  }
84
86
  }
85
87
  export function mergeWatchlistStores(localRaw, importedRaw) {
88
+ assertWorkspaceInputGraph(localRaw, 'Local watchlist store');
89
+ assertWorkspaceInputGraph(importedRaw, 'Imported watchlist document');
90
+ assertWorkspacePortableVersion(importedRaw, WATCHLIST_SCHEMA_VERSION, 'Imported watchlist document');
86
91
  validateImportShape(importedRaw);
87
92
  const importedVersion = watchlistStoreVersion(importedRaw);
88
93
  if (importedVersion !== null && importedVersion > WATCHLIST_SCHEMA_VERSION) {
@@ -117,11 +122,13 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
117
122
  return { watchlists: local, added, updated, skipped };
118
123
  }
119
124
  export function buildWatchlistExport(watchlists, nowIso = new Date().toISOString()) {
120
- const parsed = typeof nowIso === 'string' ? Date.parse(nowIso) : Number.NaN;
125
+ const exportedAt = normalizeExplicitIsoTimestamp(nowIso);
126
+ if (!exportedAt)
127
+ throw new Error('Watchlist export time must use an explicit timezone.');
121
128
  return {
122
129
  schema: WATCHLIST_SCHEMA,
123
130
  version: WATCHLIST_SCHEMA_VERSION,
124
- exportedAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString(),
131
+ exportedAt,
125
132
  watchlists: normalizeWatchlistStore(watchlists).watchlists,
126
133
  };
127
134
  }
@@ -1,17 +1,14 @@
1
- import { normalizeDomain } from "./case-model.js";
2
- export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
3
- export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 4;
4
- export const SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, WEBSITE_SNAPSHOT_SCHEMA_VERSION]);
5
- export const MAX_WEBSITE_SNAPSHOTS = 60;
6
- export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
7
- export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
8
- export const MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES = 768 * 1024;
1
+ import { normalizeDomain } from "../cases/case-model.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
+ import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
+ export { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
9
6
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
10
7
  const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
11
8
  const SHA256_RE = /^[a-f0-9]{64}$/iu;
12
9
  const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
13
10
  function record(value) {
14
- return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
11
+ return ordinaryWorkspaceRecord(value, 'Website-snapshot input');
15
12
  }
16
13
  function text(value, maximum) {
17
14
  return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
@@ -19,9 +16,7 @@ function text(value, maximum) {
19
16
  : '';
20
17
  }
21
18
  function timestamp(value) {
22
- const candidate = text(value, 64);
23
- const parsed = Date.parse(candidate);
24
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
19
+ return normalizeExplicitIsoTimestamp(value) ?? '';
25
20
  }
26
21
  function digest(value) {
27
22
  const candidate = text(value, 128).toLowerCase();
@@ -184,6 +179,8 @@ export function normalizeWebsiteProfileSnapshot(raw) {
184
179
  };
185
180
  }
186
181
  export function normalizeWebsiteSnapshotStore(raw) {
182
+ assertWorkspaceInputGraph(raw, 'Website-snapshot store');
183
+ assertWorkspaceDeclaredVersion(raw, 'Website-snapshot store');
187
184
  const value = record(raw);
188
185
  if (value?.schema === WEBSITE_SNAPSHOT_SCHEMA
189
186
  && Number(value?.version) > WEBSITE_SNAPSHOT_SCHEMA_VERSION) {
@@ -195,7 +192,7 @@ export function normalizeWebsiteSnapshotStore(raw) {
195
192
  throw new Error('This website-snapshot collection uses an unsupported schema and cannot be read safely.');
196
193
  }
197
194
  const sourceValues = Array.isArray(raw) ? raw : Array.isArray(value?.snapshots) ? value.snapshots : [];
198
- const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, normalizeWebsiteProfileSnapshot);
195
+ const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, (candidate) => normalizeWebsiteProfileSnapshot(candidate));
199
196
  const perDomain = new Map();
200
197
  const retained = [];
201
198
  for (const snapshot of snapshots.sort((left, right) => right.savedAt.localeCompare(left.savedAt))) {
@@ -236,6 +233,9 @@ export function buildWebsiteSnapshotExport(raw, generatedAt = new Date().toISOSt
236
233
  return { ...store, generatedAt: timestamp(generatedAt) || new Date().toISOString() };
237
234
  }
238
235
  export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
236
+ assertWorkspaceInputGraph(localRaw, 'Local website-snapshot store');
237
+ assertWorkspaceInputGraph(incomingRaw, 'Imported website-snapshot document');
238
+ assertWorkspacePortableVersion(incomingRaw, WEBSITE_SNAPSHOT_SCHEMA_VERSION, 'Imported website-snapshot document');
239
239
  const local = normalizeWebsiteSnapshotStore(localRaw).snapshots;
240
240
  const incoming = normalizeWebsiteSnapshotStore(incomingRaw).snapshots;
241
241
  const byId = new Map(local.map((item) => [item.id, item]));
@@ -1,19 +1,18 @@
1
1
  // Browser-local authenticated encryption for portable workspace archives.
2
2
  // This protects a downloaded file while it is locked. It does not encrypt the
3
3
  // active IndexedDB workspace or defend an unlocked page from same-origin code.
4
- import { MAX_WORKSPACE_ARCHIVE_BYTES, WORKSPACE_ARCHIVE_SCHEMA, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.js";
5
- export const ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA = 'whoisleuth.encrypted-workspace-archive';
6
- export const ENCRYPTED_WORKSPACE_ARCHIVE_VERSION = 1;
7
- export const WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS = 600_000;
8
- export const MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS = 12;
9
- export const MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES = 1024;
10
- export const MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES = Math.ceil(MAX_WORKSPACE_ARCHIVE_BYTES * 4 / 3) + 4096;
4
+ import { WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_VERSION, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.mjs";
5
+ import { parseBoundedJson } from "../../lib/bounded-json.mjs";
6
+ import { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, } from "../contracts/case-portability.mjs";
7
+ import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
8
+ export { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, };
11
9
  const SALT_BYTES = 16;
12
10
  const IV_BYTES = 12;
13
11
  const AES_KEY_BITS = 256;
14
12
  const AES_GCM_TAG_BITS = 128;
15
13
  const MAX_CIPHERTEXT_BASE64URL_CHARACTERS = Math.ceil((MAX_WORKSPACE_ARCHIVE_BYTES + AES_GCM_TAG_BITS / 8) * 4 / 3);
16
14
  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
15
+ const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
17
16
  const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
18
17
  const encoder = new TextEncoder();
19
18
  const decoder = new TextDecoder('utf-8', { fatal: true });
@@ -46,11 +45,20 @@ function assertPassphrase(passphrase) {
46
45
  return bytes;
47
46
  }
48
47
  function base64url(bytes) {
49
- let binary = '';
50
- for (let index = 0; index < bytes.length; index += 0x8000) {
51
- binary += String.fromCharCode(...bytes.subarray(index, Math.min(index + 0x8000, bytes.length)));
48
+ let encoded = '';
49
+ for (let index = 0; index < bytes.length; index += 3) {
50
+ const first = bytes[index] ?? 0;
51
+ const second = bytes[index + 1] ?? 0;
52
+ const third = bytes[index + 2] ?? 0;
53
+ const aggregate = (first << 16) | (second << 8) | third;
54
+ encoded += BASE64URL_ALPHABET[(aggregate >>> 18) & 63];
55
+ encoded += BASE64URL_ALPHABET[(aggregate >>> 12) & 63];
56
+ if (index + 1 < bytes.length)
57
+ encoded += BASE64URL_ALPHABET[(aggregate >>> 6) & 63];
58
+ if (index + 2 < bytes.length)
59
+ encoded += BASE64URL_ALPHABET[aggregate & 63];
52
60
  }
53
- return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
61
+ return encoded;
54
62
  }
55
63
  function decodeBase64url(value, expectedBytes, label) {
56
64
  const expectedCharacters = expectedBytes === null ? null : Math.ceil(expectedBytes * 4 / 3);
@@ -60,15 +68,25 @@ function decodeBase64url(value, expectedBytes, label) {
60
68
  || !BASE64URL_RE.test(value)) {
61
69
  throw new Error(`The encrypted workspace archive has an invalid ${label}.`);
62
70
  }
63
- const padding = (4 - value.length % 4) % 4;
64
- let binary = '';
65
- try {
66
- binary = atob(value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat(padding));
67
- }
68
- catch {
71
+ if (value.length % 4 === 1) {
69
72
  throw new Error(`The encrypted workspace archive has an invalid ${label}.`);
70
73
  }
71
- const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
74
+ const bytes = new Uint8Array(Math.floor(value.length * 6 / 8));
75
+ let outputIndex = 0;
76
+ let accumulator = 0;
77
+ let bits = 0;
78
+ for (const character of value) {
79
+ const decoded = BASE64URL_ALPHABET.indexOf(character);
80
+ if (decoded < 0)
81
+ throw new Error(`The encrypted workspace archive has an invalid ${label}.`);
82
+ accumulator = (accumulator << 6) | decoded;
83
+ bits += 6;
84
+ if (bits >= 8) {
85
+ bits -= 8;
86
+ bytes[outputIndex] = (accumulator >>> bits) & 0xff;
87
+ outputIndex += 1;
88
+ }
89
+ }
72
90
  if (expectedBytes !== null && bytes.byteLength !== expectedBytes) {
73
91
  throw new Error(`The encrypted workspace archive has an invalid ${label}.`);
74
92
  }
@@ -94,6 +112,7 @@ function validTimestamp(value) {
94
112
  return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
95
113
  }
96
114
  function validateEnvelope(raw) {
115
+ assertWorkspaceInputGraph(raw, 'Encrypted workspace archive');
97
116
  const value = record(raw);
98
117
  if (!value || value.schema !== ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA) {
99
118
  throw new Error('This file is not an encrypted WHOISleuth workspace archive.');
@@ -111,6 +130,14 @@ function validateEnvelope(raw) {
111
130
  throw new Error('The encrypted workspace archive has an invalid creation time.');
112
131
  }
113
132
  const content = record(value.content);
133
+ if (content?.schema === WORKSPACE_ARCHIVE_SCHEMA && Number.isSafeInteger(content.version)) {
134
+ if (content.version < Math.min(...SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS)) {
135
+ throw new Error(`Encrypted workspace content schema ${String(content.version)} is retired. Decrypt and re-export it as schema ${WORKSPACE_ARCHIVE_VERSION} with the last broad-reader release; no data was changed.`);
136
+ }
137
+ if (content.version > WORKSPACE_ARCHIVE_VERSION) {
138
+ throw new Error(`Encrypted workspace content schema ${String(content.version)} is newer than the supported schema ${WORKSPACE_ARCHIVE_VERSION}; no data was changed.`);
139
+ }
140
+ }
114
141
  if (!content
115
142
  || !hasExactKeys(content, ['schema', 'version'])
116
143
  || content.schema !== WORKSPACE_ARCHIVE_SCHEMA
@@ -266,7 +293,10 @@ export async function decryptWorkspaceArchive(raw, passphrase, provider) {
266
293
  if (plaintext.byteLength > MAX_WORKSPACE_ARCHIVE_BYTES) {
267
294
  throw new Error('The decrypted workspace archive exceeds its byte limit.');
268
295
  }
269
- return JSON.parse(decoder.decode(plaintext));
296
+ return parseBoundedJson(decoder.decode(plaintext), {
297
+ label: 'Decrypted workspace archive',
298
+ maximumBytes: MAX_WORKSPACE_ARCHIVE_BYTES,
299
+ });
270
300
  }
271
301
  catch (cause) {
272
302
  if (cause instanceof Error && cause.message === 'The decrypted workspace archive exceeds its byte limit.')