@slicedearth/whoisleuth-cli 1.47.4 → 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 (235) 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/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -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);
@@ -0,0 +1,172 @@
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
+ function inputError(label, detail) {
3
+ return new TypeError(`${label} must be bounded ordinary JSON data; ${detail}.`);
4
+ }
5
+ function ownDataDescriptors(value, label) {
6
+ let keys;
7
+ try {
8
+ keys = Reflect.ownKeys(value);
9
+ }
10
+ catch {
11
+ throw inputError(label, 'its property keys could not be inspected safely');
12
+ }
13
+ if (keys.some((key) => typeof key !== 'string')) {
14
+ throw inputError(label, 'symbol keys are not supported');
15
+ }
16
+ return keys.map((key) => {
17
+ let descriptor;
18
+ try {
19
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
20
+ }
21
+ catch {
22
+ throw inputError(label, 'a property descriptor could not be inspected safely');
23
+ }
24
+ if (!descriptor || !('value' in descriptor)) {
25
+ throw inputError(label, 'accessor properties are not supported');
26
+ }
27
+ return [key, descriptor];
28
+ });
29
+ }
30
+ export function ordinaryWorkspaceRecord(value, label = 'Workspace input') {
31
+ if (!value || typeof value !== 'object' || Array.isArray(value))
32
+ return null;
33
+ let prototype;
34
+ try {
35
+ prototype = Object.getPrototypeOf(value);
36
+ }
37
+ catch {
38
+ throw inputError(label, 'its prototype could not be inspected safely');
39
+ }
40
+ if (prototype !== Object.prototype && prototype !== null) {
41
+ throw inputError(label, 'custom object prototypes are not supported');
42
+ }
43
+ const descriptors = ownDataDescriptors(value, label);
44
+ if (descriptors.length > MAX_WORKSPACE_INPUT_OBJECT_KEYS) {
45
+ throw inputError(label, 'an object exceeds the key ceiling');
46
+ }
47
+ if (descriptors.some(([, descriptor]) => !descriptor.enumerable)) {
48
+ throw inputError(label, 'non-enumerable object fields are not supported');
49
+ }
50
+ return value;
51
+ }
52
+ export function assertWorkspaceInputGraph(value, label = 'Workspace input') {
53
+ const pending = [{ value, depth: 0, label }];
54
+ const seen = new WeakSet();
55
+ let nodes = 0;
56
+ let stringCodeUnits = 0;
57
+ while (pending.length) {
58
+ const current = pending.pop();
59
+ const candidate = current.value;
60
+ if (typeof candidate === 'string') {
61
+ stringCodeUnits += candidate.length;
62
+ if (stringCodeUnits > MAX_WORKSPACE_INPUT_STRING_CODE_UNITS) {
63
+ throw inputError(label, 'aggregate text exceeds the string ceiling');
64
+ }
65
+ continue;
66
+ }
67
+ if (candidate === null
68
+ || candidate === undefined
69
+ || typeof candidate === 'boolean'
70
+ || typeof candidate === 'number')
71
+ continue;
72
+ if (typeof candidate !== 'object') {
73
+ throw inputError(current.label, 'non-JSON values are not supported');
74
+ }
75
+ if (current.depth > MAX_WORKSPACE_INPUT_GRAPH_DEPTH) {
76
+ throw inputError(label, 'the nesting depth exceeds the graph ceiling');
77
+ }
78
+ nodes += 1;
79
+ if (nodes > MAX_WORKSPACE_INPUT_GRAPH_NODES) {
80
+ throw inputError(label, 'the graph exceeds the node ceiling');
81
+ }
82
+ if (seen.has(candidate))
83
+ continue;
84
+ seen.add(candidate);
85
+ if (Array.isArray(candidate)) {
86
+ let prototype;
87
+ try {
88
+ prototype = Object.getPrototypeOf(candidate);
89
+ }
90
+ catch {
91
+ throw inputError(current.label, 'its array prototype could not be inspected safely');
92
+ }
93
+ if (prototype !== Array.prototype) {
94
+ throw inputError(current.label, 'custom array prototypes are not supported');
95
+ }
96
+ const descriptors = ownDataDescriptors(candidate, current.label);
97
+ const lengthDescriptor = descriptors.find(([key]) => key === 'length')?.[1];
98
+ const length = lengthDescriptor?.value;
99
+ if (!Number.isSafeInteger(length)
100
+ || Number(length) < 0
101
+ || Number(length) > MAX_WORKSPACE_INPUT_ARRAY_LENGTH) {
102
+ throw inputError(current.label, 'an array exceeds the length ceiling');
103
+ }
104
+ if (descriptors.length !== Number(length) + 1) {
105
+ throw inputError(current.label, 'sparse arrays and custom array fields are not supported');
106
+ }
107
+ const byKey = new Map(descriptors);
108
+ for (let index = 0; index < Number(length); index += 1) {
109
+ const descriptor = byKey.get(String(index));
110
+ if (!descriptor?.enumerable) {
111
+ throw inputError(current.label, 'sparse or non-enumerable array entries are not supported');
112
+ }
113
+ pending.push({
114
+ value: descriptor.value,
115
+ depth: current.depth + 1,
116
+ label: `${current.label}[${index}]`,
117
+ });
118
+ }
119
+ continue;
120
+ }
121
+ const record = ordinaryWorkspaceRecord(candidate, current.label);
122
+ const descriptors = ownDataDescriptors(record, current.label);
123
+ for (const [key, descriptor] of descriptors) {
124
+ pending.push({
125
+ value: descriptor.value,
126
+ depth: current.depth + 1,
127
+ label: `${current.label}.${key}`,
128
+ });
129
+ }
130
+ }
131
+ }
132
+ export function assertWorkspacePortableVersion(value, currentVersion, label) {
133
+ if (!Number.isSafeInteger(currentVersion) || currentVersion < 1) {
134
+ throw new TypeError(`${label} current version is invalid.`);
135
+ }
136
+ if (Array.isArray(value)) {
137
+ return null;
138
+ }
139
+ const record = ordinaryWorkspaceRecord(value, label);
140
+ if (!record)
141
+ throw inputError(label, 'the root must be an object');
142
+ const descriptor = Object.getOwnPropertyDescriptor(record, 'version');
143
+ if (!descriptor)
144
+ return null;
145
+ if (!('value' in descriptor))
146
+ throw inputError(label, 'the version must be a positive safe integer');
147
+ if (typeof descriptor.value === 'number'
148
+ && Number.isFinite(descriptor.value)
149
+ && descriptor.value > currentVersion) {
150
+ return descriptor.value;
151
+ }
152
+ if (!Number.isSafeInteger(descriptor.value) || Number(descriptor.value) < 1) {
153
+ throw inputError(label, 'the version must be a positive safe integer');
154
+ }
155
+ const version = Number(descriptor.value);
156
+ return version;
157
+ }
158
+ export function assertWorkspaceDeclaredVersion(value, label) {
159
+ if (Array.isArray(value) || value === null || value === undefined)
160
+ return;
161
+ const record = ordinaryWorkspaceRecord(value, label);
162
+ if (!record)
163
+ return;
164
+ const descriptor = Object.getOwnPropertyDescriptor(record, 'version');
165
+ if (!descriptor)
166
+ return;
167
+ if (!('value' in descriptor)
168
+ || !Number.isSafeInteger(descriptor.value)
169
+ || Number(descriptor.value) < 1) {
170
+ throw inputError(label, 'a declared version must be a positive safe integer');
171
+ }
172
+ }
@@ -1,10 +1,10 @@
1
- import { normalizeDomain } from "./case-model.js";
1
+ import { normalizeDomain } from "../cases/case-model.mjs";
2
2
  import { parse } from 'tldts';
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
4
  export const INVESTIGATION_GUIDE_SCHEMA = 'whoisleuth.investigation-recipe';
4
5
  export const INVESTIGATION_GUIDE_VERSION = 5;
5
- export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
6
6
  export const INVESTIGATION_GUIDE_EXPORT_VERSION = 4;
7
- export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, 4, INVESTIGATION_GUIDE_VERSION];
7
+ export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [INVESTIGATION_GUIDE_VERSION];
8
8
  export const INVESTIGATION_GUIDE_EXPORT_SCHEMA = 'whoisleuth.investigation-recipe-summary';
9
9
  export const MAX_INVESTIGATION_GUIDE_DOMAIN_LENGTH = 253;
10
10
  export const MAX_INVESTIGATION_GUIDE_REVIEW_DOMAINS = 25;
@@ -16,7 +16,6 @@ const CONTROL_RE = /[\x00-\x1f\x7f]/u;
16
16
  const SAFE_TEMPLATE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
17
17
  const GUIDE_STATUSES = new Set(['active', 'paused']);
18
18
  const GUIDE_OUTCOMES = new Set(['pending', 'complete', 'partial', 'skipped']);
19
- const PRE_RESPONSE_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
20
19
  function stage(id, workspace, label, detail, expectedEvidence, requestImpact, prerequisite, completionCriteria, instructions, requiresApproval) {
21
20
  return Object.freeze({
22
21
  id,
@@ -130,10 +129,7 @@ function record(value) {
130
129
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
131
130
  }
132
131
  function timestamp(value) {
133
- if (typeof value !== 'string' || value.length > MAX_INVESTIGATION_GUIDE_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
134
- return '';
135
- const parsed = Date.parse(value);
136
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
132
+ return normalizeExplicitIsoTimestamp(value) ?? '';
137
133
  }
138
134
  function nullableTimestamp(value) {
139
135
  if (value === null || value === undefined || value === '')
@@ -324,53 +320,22 @@ export function createInvestigationGuide(domain, recipeId = 'new_domain_triage',
324
320
  stages: stages.map((stageDefinition) => createStageProgress(stageDefinition, createdAt)),
325
321
  };
326
322
  }
327
- function parseLegacyGuide(input) {
328
- const domain = normalizeInvestigationGuideDomain(input.domain);
329
- const createdAt = timestamp(input.createdAt);
330
- const updatedAt = timestamp(input.updatedAt);
331
- const recipe = investigationGuideRecipe('new_domain_triage');
332
- if (!domain || !createdAt || !updatedAt || !recipe)
333
- return null;
334
- const opened = new Set((Array.isArray(input.visitedStages) ? input.visitedStages : [])
335
- .slice(0, recipe.stages.length * 2)
336
- .filter((value) => typeof value === 'string'));
337
- return {
338
- version: INVESTIGATION_GUIDE_VERSION,
339
- recipeId: recipe.id,
340
- template: null,
341
- domain,
342
- focusDomain: null,
343
- reviewDomains: [domain],
344
- reviewDomainsTruncated: false,
345
- status: 'active',
346
- createdAt,
347
- updatedAt,
348
- stages: recipe.stages.map((stageDefinition) => ({
349
- ...createStageProgress(stageDefinition, updatedAt),
350
- openedAt: opened.has(stageDefinition.id) ? updatedAt : null,
351
- })),
352
- };
353
- }
354
323
  export function parseInvestigationGuide(value) {
355
324
  const input = record(value);
356
325
  if (!input)
357
326
  return null;
358
- if (input.version === INVESTIGATION_GUIDE_LEGACY_VERSION)
359
- return parseLegacyGuide(input);
360
327
  if (!INVESTIGATION_GUIDE_SUPPORTED_VERSIONS.includes(input.version))
361
328
  return null;
362
329
  const recipe = investigationGuideRecipe(input.recipeId);
363
330
  const domain = normalizeInvestigationGuideDomain(input.domain);
364
331
  const createdAt = timestamp(input.createdAt);
365
332
  const updatedAt = timestamp(input.updatedAt);
366
- if (!recipe || !domain || !createdAt || !updatedAt
367
- || (input.version !== INVESTIGATION_GUIDE_VERSION && !PRE_RESPONSE_RECIPE_IDS.has(recipe.id)))
333
+ if (!recipe || !domain || !createdAt || !updatedAt)
368
334
  return null;
369
- const supportsTemplate = input.version === 3 || input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION;
370
- const template = supportsTemplate && input.template !== null && input.template !== undefined
335
+ const template = input.template !== null && input.template !== undefined
371
336
  ? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
372
337
  : null;
373
- if (supportsTemplate && input.template !== null && input.template !== undefined && !template)
338
+ if (input.template !== null && input.template !== undefined && !template)
374
339
  return null;
375
340
  const stageDefinitions = template?.stages || recipe.stages;
376
341
  const normalizedReview = normalizeReviewDomains(input.reviewDomains);
@@ -401,9 +366,7 @@ export function parseInvestigationGuide(value) {
401
366
  outcome: guideOutcome(item?.outcome),
402
367
  approvedAt: nullableTimestamp(item?.approvedAt),
403
368
  openedAt: nullableTimestamp(item?.openedAt),
404
- reviewNote: input.version === 4 || input.version === INVESTIGATION_GUIDE_VERSION
405
- ? boundedReviewNote(item?.reviewNote)
406
- : null,
369
+ reviewNote: boundedReviewNote(item?.reviewNote),
407
370
  updatedAt: timestamp(item?.updatedAt) || updatedAt,
408
371
  };
409
372
  }),
@@ -1,30 +1,23 @@
1
- import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
2
- export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
3
- export const INVESTIGATION_TEMPLATE_VERSION = 2;
4
- export const INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS = [1, INVESTIGATION_TEMPLATE_VERSION];
5
- export const MAX_INVESTIGATION_TEMPLATES = 20;
6
- export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
7
- export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
8
- const CONTROL_RE = /[\x00-\x1f\x7f]/u;
9
- const LEGACY_RECIPE_IDS = new Set(['brand_sweep', 'infrastructure_pivot', 'new_domain_triage']);
1
+ import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
+ import { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS, INVESTIGATION_TEMPLATE_VERSION, MAX_INVESTIGATION_TEMPLATES, MAX_INVESTIGATION_TEMPLATE_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
5
+ export { INVESTIGATION_TEMPLATE_SCHEMA, INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS, INVESTIGATION_TEMPLATE_VERSION, MAX_INVESTIGATION_TEMPLATES, MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES, MAX_INVESTIGATION_TEMPLATE_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
10
6
  function record(value) {
11
- return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
7
+ return ordinaryWorkspaceRecord(value, 'Investigation-template input');
12
8
  }
13
9
  function timestamp(value) {
14
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
15
- return '';
16
- const parsed = Date.parse(value);
17
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
10
+ return normalizeExplicitIsoTimestamp(value) ?? '';
18
11
  }
19
12
  function byteLength(value) {
20
13
  return new TextEncoder().encode(value).byteLength;
21
14
  }
22
- function template(raw, sourceVersion = INVESTIGATION_TEMPLATE_VERSION) {
15
+ function template(raw) {
23
16
  const value = record(raw);
24
17
  const snapshot = normalizeInvestigationGuideTemplateSnapshot(value);
25
18
  const createdAt = timestamp(value?.createdAt);
26
19
  const updatedAt = timestamp(value?.updatedAt);
27
- return snapshot && createdAt && updatedAt && (sourceVersion !== 1 || LEGACY_RECIPE_IDS.has(snapshot.recipeId))
20
+ return snapshot && createdAt && updatedAt
28
21
  ? { ...snapshot, createdAt, updatedAt }
29
22
  : null;
30
23
  }
@@ -32,17 +25,18 @@ export function normalizeInvestigationTemplate(raw) {
32
25
  return template(raw);
33
26
  }
34
27
  export function normalizeInvestigationTemplateStore(raw) {
28
+ assertWorkspaceInputGraph(raw, 'Investigation-template store');
29
+ assertWorkspaceDeclaredVersion(raw, 'Investigation-template store');
35
30
  const value = record(raw);
36
31
  if (value?.schema === INVESTIGATION_TEMPLATE_SCHEMA
37
- && Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
38
- throw new Error('This investigation-template collection uses a newer schema and cannot be read safely.');
32
+ && !INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
33
+ throw new Error(`Investigation-template schema ${String(value.version)} is unsupported; no data was changed.`);
39
34
  }
40
- const sourceVersion = Array.isArray(raw) ? INVESTIGATION_TEMPLATE_VERSION : Number(value?.version) || INVESTIGATION_TEMPLATE_VERSION;
41
35
  const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
42
36
  const templates = [];
43
37
  const seen = new Set();
44
38
  for (const candidate of source.slice(0, MAX_INVESTIGATION_TEMPLATES * 2)) {
45
- const normalized = template(candidate, sourceVersion);
39
+ const normalized = template(candidate);
46
40
  if (!normalized || seen.has(normalized.id))
47
41
  continue;
48
42
  seen.add(normalized.id);
@@ -119,11 +113,14 @@ function strictImport(raw) {
119
113
  throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
120
114
  }
121
115
  if (!INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
122
- throw new Error(`Expected investigation-template schema 1 or ${INVESTIGATION_TEMPLATE_VERSION}.`);
116
+ throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
123
117
  }
124
118
  return normalizeInvestigationTemplateStore(value).templates;
125
119
  }
126
120
  export function mergeInvestigationTemplates(localRaw, incomingRaw) {
121
+ assertWorkspaceInputGraph(localRaw, 'Local investigation-template store');
122
+ assertWorkspaceInputGraph(incomingRaw, 'Imported investigation-template document');
123
+ assertWorkspacePortableVersion(incomingRaw, INVESTIGATION_TEMPLATE_VERSION, 'Imported investigation-template document');
127
124
  const local = normalizeInvestigationTemplateStore(localRaw).templates;
128
125
  const incoming = strictImport(incomingRaw);
129
126
  const byId = new Map(local.map((item) => [item.id, item]));
@@ -2,18 +2,14 @@
2
2
  // deliberately accepts only bounded, normalized page-identity fields. Raw
3
3
  // HTML, URLs, headers, redirect inventories, and parser diagnostics never
4
4
  // cross this storage boundary.
5
- import { normalizeDomain } from "./case-model.js";
6
- import { isInformativeFaviconHash } from "./utils.js";
7
- export const PAGE_BASELINE_VERSION = 1;
8
- export const PAGE_IDENTITY_VERSION = 3;
9
- export const PAGE_FINGERPRINT_VERSION = 1;
10
- export const MAX_BASELINE_TITLE_LENGTH = 200;
11
- export const MAX_BASELINE_RESOURCE_HOSTS = 30;
12
- export const MAX_BASELINE_IDENTIFIERS = 30;
5
+ import { normalizeDomain } from "../cases/case-model.mjs";
6
+ import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
+ import { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
9
+ export { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
13
10
  const SHA256_RE = /^[a-f0-9]{64}$/i;
14
11
  const SIMHASH_RE = /^[a-f0-9]{16}$/i;
15
12
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
16
- const MAX_TIMESTAMP_LENGTH = 64;
17
13
  const MAX_HTML_TOKENS = 4096;
18
14
  const MAX_TEXT_TOKENS = 8192;
19
15
  const MAX_FORMS = 50;
@@ -24,10 +20,7 @@ function record(value) {
24
20
  : null;
25
21
  }
26
22
  function timestamp(value) {
27
- if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
28
- return null;
29
- const parsed = new Date(value);
30
- return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
23
+ return normalizeExplicitIsoTimestamp(value);
31
24
  }
32
25
  function count(value, maximum) {
33
26
  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum
@@ -1,12 +1,8 @@
1
- import { normalizeDomain } from "./case-model.js";
2
- export const RELATIONSHIP_OBSERVATION_SCHEMA = 'whoisleuth.relationship-observations';
3
- export const RELATIONSHIP_OBSERVATION_SCHEMA_VERSION = 1;
4
- export const MAX_RELATIONSHIP_OBSERVATIONS = 300;
5
- export const MAX_RELATIONSHIP_OBSERVATION_INPUTS = 1_200;
6
- export const MAX_RELATIONSHIP_OBSERVATION_DOMAINS = 50;
7
- export const MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS = 8;
8
- export const MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH = 20_000;
9
- export const MAX_RELATIONSHIP_OBSERVATION_STORE_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_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
+ export { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
10
6
  export const RELATIONSHIP_OBSERVATION_TYPES = Object.freeze([
11
7
  'nameserver_set',
12
8
  'ip_address',
@@ -48,7 +44,7 @@ const TYPE_METADATA = Object.freeze({
48
44
  description: 'These domains used an identical or perceptually similar favicon in the retained scan.',
49
45
  }),
50
46
  official_asset: Object.freeze({
51
- label: 'Official asset relationship',
47
+ label: 'Official asset host match',
52
48
  method: 'Configured-domain host match',
53
49
  description: 'One or more pages loaded an asset from this configured official domain or its subdomain.',
54
50
  }),
@@ -58,9 +54,7 @@ const FIXED_LIMITATIONS = Object.freeze([
58
54
  'A shared observation does not prove ownership, coordination, intent, or maliciousness.',
59
55
  ]);
60
56
  function record(value) {
61
- return value && typeof value === 'object' && !Array.isArray(value)
62
- ? value
63
- : null;
57
+ return ordinaryWorkspaceRecord(value, 'Relationship-observation input');
64
58
  }
65
59
  function text(value, maximum) {
66
60
  if (typeof value !== 'string' || value.length > maximum * 4 || CONTROL_RE.test(value))
@@ -68,10 +62,7 @@ function text(value, maximum) {
68
62
  return value.replace(/\s+/gu, ' ').trim().slice(0, maximum).trim();
69
63
  }
70
64
  function timestamp(value) {
71
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
72
- return '';
73
- const parsed = Date.parse(value);
74
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
65
+ return normalizeExplicitIsoTimestamp(value) ?? '';
75
66
  }
76
67
  function positiveInteger(value) {
77
68
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 1_000
@@ -262,6 +253,8 @@ export function normalizeRelationshipObservation(raw) {
262
253
  };
263
254
  }
264
255
  export function normalizeRelationshipObservationStore(raw) {
256
+ assertWorkspaceInputGraph(raw, 'Relationship-observation store');
257
+ assertWorkspaceDeclaredVersion(raw, 'Relationship-observation store');
265
258
  const byId = new Map();
266
259
  for (const candidate of observationList(raw).slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
267
260
  const observation = normalizeRelationshipObservation(candidate);
@@ -361,6 +354,9 @@ export function deleteRelationshipObservation(rawStore, id) {
361
354
  return normalizeRelationshipObservationStore(rawStore).observations.filter((item) => item.id !== safeId);
362
355
  }
363
356
  export function mergeRelationshipObservations(localRaw, importedRaw) {
357
+ assertWorkspaceInputGraph(localRaw, 'Local relationship-observation store');
358
+ assertWorkspaceInputGraph(importedRaw, 'Imported relationship-observation document');
359
+ assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
364
360
  const importedRecord = record(importedRaw);
365
361
  if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
366
362
  throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
@@ -1,23 +1,18 @@
1
1
  // Pure browser-local shortlist model. The browser wrapper owns persistence
2
2
  // and downloads; this module owns schema migration, record normalization,
3
3
  // import merging, and exact serialized-byte accounting.
4
- import { compactWatchlistResults } from "./watchlist-history.js";
5
- import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
6
- export const SHORTLIST_SCHEMA = 'whoisleuth.shortlist';
7
- export const SHORTLIST_SCHEMA_VERSION = 3;
8
- export const SUPPORTED_SHORTLIST_SCHEMA_VERSIONS = Object.freeze([2, SHORTLIST_SCHEMA_VERSION]);
9
- export const MAX_SHORTLIST_ENTRIES = 500;
10
- export const MAX_SHORTLIST_INPUTS = MAX_SHORTLIST_ENTRIES * 4;
11
- export const MAX_SHORTLIST_STORE_BYTES = 1024 * 1024;
12
- export const MAX_SHORTLIST_FACTORS = 20;
4
+ import { compactWatchlistResults } from "./watchlist-history.mjs";
5
+ import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
6
+ import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
+ import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
9
+ import { MAX_SHORTLIST_ENTRIES, MAX_SHORTLIST_FACTORS, MAX_SHORTLIST_INPUTS, MAX_SHORTLIST_STORE_BYTES, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, SUPPORTED_SHORTLIST_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
10
+ export { MAX_SHORTLIST_ENTRIES, MAX_SHORTLIST_FACTORS, MAX_SHORTLIST_INPUTS, MAX_SHORTLIST_STORE_BYTES, SHORTLIST_SCHEMA, SHORTLIST_SCHEMA_VERSION, SUPPORTED_SHORTLIST_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
13
11
  const MAX_FACTOR_LABEL_LENGTH = 200;
14
- const MAX_TIMESTAMP_LENGTH = 64;
15
12
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
16
13
  const EPOCH = new Date(0).toISOString();
17
14
  function plainRecord(value) {
18
- return value && typeof value === 'object' && !Array.isArray(value)
19
- ? value
20
- : null;
15
+ return ordinaryWorkspaceRecord(value, 'Shortlist input');
21
16
  }
22
17
  function isEnvelope(value) {
23
18
  return Boolean(value && value.schema === SHORTLIST_SCHEMA && Array.isArray(value.entries));
@@ -30,7 +25,7 @@ function entryList(raw) {
30
25
  }
31
26
  export function shortlistStoreVersion(raw) {
32
27
  if (Array.isArray(raw))
33
- return 1;
28
+ return SHORTLIST_SCHEMA_VERSION;
34
29
  const value = plainRecord(raw);
35
30
  if (!value || !isEnvelope(value))
36
31
  return null;
@@ -44,10 +39,7 @@ function score(value) {
44
39
  : null;
45
40
  }
46
41
  function timestamp(value, fallback = EPOCH) {
47
- if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
48
- return fallback;
49
- const parsed = Date.parse(value);
50
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
42
+ return normalizeExplicitIsoTimestamp(value) ?? fallback;
51
43
  }
52
44
  function factors(value) {
53
45
  if (!Array.isArray(value))
@@ -93,7 +85,14 @@ export function normalizeShortlistRecord(raw, options = {}) {
93
85
  }
94
86
  /** Normalize an internal record collection or current stored envelope. */
95
87
  export function normalizeShortlistStore(raw) {
88
+ assertWorkspaceInputGraph(raw, 'Shortlist store');
89
+ assertWorkspaceDeclaredVersion(raw, 'Shortlist store');
96
90
  const source = entryList(raw);
91
+ const sourceVersion = shortlistStoreVersion(raw);
92
+ if (!Array.isArray(raw) && sourceVersion !== null
93
+ && !SUPPORTED_SHORTLIST_SCHEMA_VERSIONS.includes(sourceVersion)) {
94
+ throw new Error(`Shortlist schema ${sourceVersion} is unsupported; no data was changed.`);
95
+ }
97
96
  const byDomain = new Map();
98
97
  if (source) {
99
98
  for (const item of source.slice(0, MAX_SHORTLIST_INPUTS)) {
@@ -167,6 +166,9 @@ function validateImport(raw) {
167
166
  }
168
167
  }
169
168
  export function mergeShortlistStores(localRaw, importedRaw) {
169
+ assertWorkspaceInputGraph(localRaw, 'Local shortlist store');
170
+ assertWorkspaceInputGraph(importedRaw, 'Imported shortlist document');
171
+ assertWorkspacePortableVersion(importedRaw, SHORTLIST_SCHEMA_VERSION, 'Imported shortlist document');
170
172
  validateImport(importedRaw);
171
173
  const importedVersion = shortlistStoreVersion(importedRaw);
172
174
  if (importedVersion !== null && importedVersion > SHORTLIST_SCHEMA_VERSION) {