@slicedearth/whoisleuth-cli 1.45.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -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
- export const INVESTIGATION_GUIDE_VERSION = 4;
5
- export const INVESTIGATION_GUIDE_LEGACY_VERSION = 1;
6
- export const INVESTIGATION_GUIDE_EXPORT_VERSION = 3;
7
- export const INVESTIGATION_GUIDE_SUPPORTED_VERSIONS = [2, 3, INVESTIGATION_GUIDE_VERSION];
5
+ export const INVESTIGATION_GUIDE_VERSION = 5;
6
+ export const INVESTIGATION_GUIDE_EXPORT_VERSION = 4;
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;
@@ -91,15 +91,45 @@ export const INVESTIGATION_RECIPES = Object.freeze([
91
91
  stage('monitor', 'monitor', 'Record disposition', 'Review the bounded domain set carried from Bulk and create or update only the cases that need an analyst decision or follow-up plan.', 'A bounded review queue plus case records with dispositions, notes, evidence history, and optional monitoring intent.', 'Local case editing makes no request. Rescans and hosted-monitor changes remain separate explicit actions.', 'Review source provenance, limitations, and any scoring explanation before deciding.', 'The analyst records the required dispositions or explicitly skips retention; the recipe never decides automatically.', ['Review the domains carried from Bulk and open only the cases that need retention.', 'Record a disposition and any concise evidence-based follow-up for each retained case.', 'Mark the step reviewed when the retained cases reflect your decision.'], false),
92
92
  ]),
93
93
  }),
94
+ Object.freeze({
95
+ id: 'credential_impersonation_response',
96
+ label: 'Credential impersonation response',
97
+ summary: 'Review observed impersonation evidence, confirm the affected brand boundary, and prepare a local response packet from explicitly recorded routes.',
98
+ targetLabel: 'Domain under review',
99
+ stages: Object.freeze([
100
+ stage('lookup', 'lookup', 'Review impersonation evidence', 'Collect separately attributed deep evidence for the domain without treating visual or naming similarity as attribution.', 'Current registry, DNS, certificate, HTTP, page-identity, and source-health observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the target is in scope and do not enter a credential-bearing URL or secret.', 'Observed facts and missing sources are reviewed without making an ownership, intent, or harm conclusion.', ['Run the pre-filled Lookup in Deep mode.', 'Review exact page, redirect, certificate, and registration observations with their source states.', 'Create or update a Case only when the retained observations need reviewed follow-up.'], true),
101
+ stage('brands', 'brands', 'Confirm affected brand boundary', 'Review the saved Brand Profile that defines official and allowlisted references; do not infer an association from similarity.', 'An analyst-selected Brand Profile boundary, or an explicit decision to continue without one.', 'Local-only. Opening Brands makes no analysis request.', 'Know which saved profile, if any, is relevant to the affected party.', 'The relevant profile boundary is reviewed, or the stage is marked partial or skipped without creating an association.', ['Open the saved Brand Profiles list.', 'Review the official references and allowlists for the affected party.', 'Return to the guide without adding a profile association unless you deliberately selected one in Monitor.'], false),
102
+ stage('monitor', 'monitor', 'Prepare reviewed response', 'Open the Case response workspace, record only analyst-selected published recipient routes, and review packet preflight.', 'A Case action with recipient provenance and limitations plus an optional local draft packet that remains unsubmitted.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Retain supporting facts and reasoning before recording an external route; confirm the route manually.', 'The reviewed action and packet preflight are recorded, or the stage is partial or skipped without implying submission.', ['Open or create the pre-filled Case, then review its evidence and reasoning.', 'Record an analyst-selected published route with its source and limitations; do not claim it is reachable.', 'Open response preflight and prepare a local draft only if its required facts are complete.'], false),
103
+ ]),
104
+ }),
105
+ Object.freeze({
106
+ id: 'mail_abuse_response',
107
+ label: 'Mail abuse response',
108
+ summary: 'Review official mail posture and observed domain evidence before preparing a source-qualified, unsent response packet.',
109
+ targetLabel: 'Domain under review',
110
+ stages: Object.freeze([
111
+ stage('brands', 'brands', 'Review official mail posture', 'Review the affected Brand Profile and retained mail posture without treating a missing control as proof of abuse.', 'Analyst-selected official-domain, mail, and Brand Profile context with explicit availability states.', 'Local-only unless the analyst separately starts a Brand posture refresh.', 'Identify the affected profile and distinguish official infrastructure from the domain under review.', 'Relevant retained posture is reviewed, or unavailable context is marked partial rather than absent.', ['Open the saved Brand Profiles list.', 'Review official MX, SPF, DMARC, and approved-domain context where retained.', 'Do not refresh or change a profile unless that separate action is necessary and approved.'], false),
112
+ stage('lookup', 'lookup', 'Review domain and mail evidence', 'Collect current deep evidence for the domain and keep DNS, mail, registration, and page observations separately attributed.', 'Authority-aware availability plus bounded DNS, mail, TLS, HTTP, and page observations where supported.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm the domain is in scope; email content and mailbox data are outside this workflow.', 'Current observations and source failures are reviewed without claiming message origin, sender control, or maliciousness.', ['Run the pre-filled Lookup in Deep mode.', 'Review MX, SPF, DMARC, registration, certificate, HTTP, and page source states.', 'Retain only the evidence needed for a reviewed Case.'], true),
113
+ stage('monitor', 'monitor', 'Prepare mail-abuse response', 'Record a reviewed recipient route and use Case packet preflight without sending or claiming delivery.', 'A bounded Case action and optional local response draft with exact recipient provenance and limitations.', 'Local Case edits and packet generation make no request. WHOISleuth never sends the packet.', 'Confirm the intended recipient and retain the observed facts and limitations that support review.', 'The current action state and packet preflight are recorded, or the stage remains partial without a delivery conclusion.', ['Open or create the pre-filled Case and review its retained evidence.', 'Record only a deliberately selected published route, its source, and known limitations.', 'Use the response preflight to prepare a local draft; submission remains a separate manual action recorded later.'], false),
114
+ ]),
115
+ }),
116
+ Object.freeze({
117
+ id: 'domain_control_change_response',
118
+ label: 'Domain-control change response',
119
+ summary: 'Review retained control posture and current authority-aware evidence before documenting a bounded internal or provider response.',
120
+ targetLabel: 'Official domain',
121
+ stages: Object.freeze([
122
+ stage('brands', 'brands', 'Review retained control posture', 'Review the saved domain-control passport, posture matrix, and source limitations before interpreting a change.', 'Retained official-domain control observations with separately attributed registry, DNS, certificate, mail, and web states.', 'Local-only unless the analyst separately starts a posture refresh.', 'Confirm the domain belongs to the reviewed Brand Profile; a saved association is analyst-selected, not inferred.', 'The retained baseline and its completeness are understood, or the stage is partial when required context is unavailable.', ['Open the relevant saved Brand Profile.', 'Review the domain-control passport, portfolio matrix, and observation times.', 'Identify which source actually supports the suspected change and preserve unavailable states.'], false),
123
+ stage('lookup', 'lookup', 'Confirm current domain evidence', 'Collect a deliberate deep Lookup so current authority-aware evidence remains distinct from the retained baseline.', 'Current registry, registrar, DNS, certificate, HTTP, and page observations with explicit source health.', 'Deep Lookup can contact several public services within explicit deadlines and response caps.', 'Confirm a fresh collection is necessary and review its request implications.', 'The current result is reviewed against the retained baseline without converting incomplete evidence into removal or compromise.', ['Run the pre-filled Lookup in Deep mode.', 'Compare current observations with the retained control evidence source by source.', 'Record only facts and limitations that need a Case response.'], true),
124
+ stage('monitor', 'monitor', 'Document control-change response', 'Record an internal or provider action and review an optional response packet without automatic enforcement or submission.', 'A bounded Case action, due or follow-up dates, recipient provenance when external, and an optional unsubmitted packet.', 'Local Case edits and packet generation make no request. WHOISleuth never changes DNS, registrar, hosting, mail, or defensive systems.', 'Document supporting evidence and confirm any recipient route or internal owner manually.', 'The reviewed action state is recorded, or the stage is partial without a compromise, delivery, or remediation claim.', ['Open or create the pre-filled Case and retain the relevant comparison facts.', 'Record the internal owner or analyst-selected provider route, source, limitations, and follow-up dates.', 'Review packet preflight when a draft is useful; record submission or resolution only after it actually occurs.'], false),
125
+ ]),
126
+ }),
94
127
  ]);
95
128
  function record(value) {
96
129
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
97
130
  }
98
131
  function timestamp(value) {
99
- if (typeof value !== 'string' || value.length > MAX_INVESTIGATION_GUIDE_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
100
- return '';
101
- const parsed = Date.parse(value);
102
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
132
+ return normalizeExplicitIsoTimestamp(value) ?? '';
103
133
  }
104
134
  function nullableTimestamp(value) {
105
135
  if (value === null || value === undefined || value === '')
@@ -239,10 +269,15 @@ export function investigationGuideHref(stageId, domain, recipeId = 'new_domain_t
239
269
  return `/discover?q=${encodeURIComponent(registrableGuideDomain(normalized))}#discovery-seed`;
240
270
  if (stageDefinition.workspace === 'bulk')
241
271
  return `/bulk?investigation=${encodeURIComponent(normalized)}#domains`;
242
- if (stageDefinition.workspace === 'monitor')
243
- return `/monitor?view=cases&investigation=1&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
244
- if (stageDefinition.workspace === 'brands')
245
- return `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
272
+ if (stageDefinition.workspace === 'monitor') {
273
+ const response = typeof recipeId === 'string' && recipeId.endsWith('_response') ? '&response=1' : '';
274
+ return `/monitor?view=cases&investigation=1${response}&domain=${encodeURIComponent(workingDomain)}#case-review-queue`;
275
+ }
276
+ if (stageDefinition.workspace === 'brands') {
277
+ return typeof recipeId === 'string' && recipeId.endsWith('_response')
278
+ ? '/brands'
279
+ : `/brands?new=1&domain=${encodeURIComponent(normalized)}#official-domains`;
280
+ }
246
281
  return stageDefinition.path;
247
282
  }
248
283
  export function investigationGuideApprovedHref(guide, stageId) {
@@ -285,39 +320,10 @@ export function createInvestigationGuide(domain, recipeId = 'new_domain_triage',
285
320
  stages: stages.map((stageDefinition) => createStageProgress(stageDefinition, createdAt)),
286
321
  };
287
322
  }
288
- function parseLegacyGuide(input) {
289
- const domain = normalizeInvestigationGuideDomain(input.domain);
290
- const createdAt = timestamp(input.createdAt);
291
- const updatedAt = timestamp(input.updatedAt);
292
- const recipe = investigationGuideRecipe('new_domain_triage');
293
- if (!domain || !createdAt || !updatedAt || !recipe)
294
- return null;
295
- const opened = new Set((Array.isArray(input.visitedStages) ? input.visitedStages : [])
296
- .slice(0, recipe.stages.length * 2)
297
- .filter((value) => typeof value === 'string'));
298
- return {
299
- version: INVESTIGATION_GUIDE_VERSION,
300
- recipeId: recipe.id,
301
- template: null,
302
- domain,
303
- focusDomain: null,
304
- reviewDomains: [domain],
305
- reviewDomainsTruncated: false,
306
- status: 'active',
307
- createdAt,
308
- updatedAt,
309
- stages: recipe.stages.map((stageDefinition) => ({
310
- ...createStageProgress(stageDefinition, updatedAt),
311
- openedAt: opened.has(stageDefinition.id) ? updatedAt : null,
312
- })),
313
- };
314
- }
315
323
  export function parseInvestigationGuide(value) {
316
324
  const input = record(value);
317
325
  if (!input)
318
326
  return null;
319
- if (input.version === INVESTIGATION_GUIDE_LEGACY_VERSION)
320
- return parseLegacyGuide(input);
321
327
  if (!INVESTIGATION_GUIDE_SUPPORTED_VERSIONS.includes(input.version))
322
328
  return null;
323
329
  const recipe = investigationGuideRecipe(input.recipeId);
@@ -326,11 +332,10 @@ export function parseInvestigationGuide(value) {
326
332
  const updatedAt = timestamp(input.updatedAt);
327
333
  if (!recipe || !domain || !createdAt || !updatedAt)
328
334
  return null;
329
- const supportsTemplate = input.version === 3 || input.version === INVESTIGATION_GUIDE_VERSION;
330
- const template = supportsTemplate && input.template !== null && input.template !== undefined
335
+ const template = input.template !== null && input.template !== undefined
331
336
  ? normalizeInvestigationGuideTemplateSnapshot(input.template, recipe.id)
332
337
  : null;
333
- if (supportsTemplate && input.template !== null && input.template !== undefined && !template)
338
+ if (input.template !== null && input.template !== undefined && !template)
334
339
  return null;
335
340
  const stageDefinitions = template?.stages || recipe.stages;
336
341
  const normalizedReview = normalizeReviewDomains(input.reviewDomains);
@@ -361,7 +366,7 @@ export function parseInvestigationGuide(value) {
361
366
  outcome: guideOutcome(item?.outcome),
362
367
  approvedAt: nullableTimestamp(item?.approvedAt),
363
368
  openedAt: nullableTimestamp(item?.openedAt),
364
- reviewNote: input.version === INVESTIGATION_GUIDE_VERSION ? boundedReviewNote(item?.reviewNote) : null,
369
+ reviewNote: boundedReviewNote(item?.reviewNote),
365
370
  updatedAt: timestamp(item?.updatedAt) || updatedAt,
366
371
  };
367
372
  }),
@@ -1,18 +1,13 @@
1
- import { INVESTIGATION_RECIPES, normalizeInvestigationGuideTemplateSnapshot, } from "./investigation-guide.js";
2
- export const INVESTIGATION_TEMPLATE_SCHEMA = 'whoisleuth.investigation-templates';
3
- export const INVESTIGATION_TEMPLATE_VERSION = 1;
4
- export const MAX_INVESTIGATION_TEMPLATES = 20;
5
- export const MAX_INVESTIGATION_TEMPLATE_STORE_BYTES = 256 * 1024;
6
- export const MAX_INVESTIGATION_TEMPLATE_IMPORT_BYTES = 384 * 1024;
7
- const CONTROL_RE = /[\x00-\x1f\x7f]/u;
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";
8
6
  function record(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
7
+ return ordinaryWorkspaceRecord(value, 'Investigation-template input');
10
8
  }
11
9
  function timestamp(value) {
12
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
13
- return '';
14
- const parsed = Date.parse(value);
15
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
10
+ return normalizeExplicitIsoTimestamp(value) ?? '';
16
11
  }
17
12
  function byteLength(value) {
18
13
  return new TextEncoder().encode(value).byteLength;
@@ -30,10 +25,12 @@ export function normalizeInvestigationTemplate(raw) {
30
25
  return template(raw);
31
26
  }
32
27
  export function normalizeInvestigationTemplateStore(raw) {
28
+ assertWorkspaceInputGraph(raw, 'Investigation-template store');
29
+ assertWorkspaceDeclaredVersion(raw, 'Investigation-template store');
33
30
  const value = record(raw);
34
31
  if (value?.schema === INVESTIGATION_TEMPLATE_SCHEMA
35
- && Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
36
- 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.`);
37
34
  }
38
35
  const source = Array.isArray(raw) ? raw : Array.isArray(value?.templates) ? value.templates : [];
39
36
  const templates = [];
@@ -115,12 +112,15 @@ function strictImport(raw) {
115
112
  if (Number(value.version) > INVESTIGATION_TEMPLATE_VERSION) {
116
113
  throw new Error('This investigation-template export uses a newer schema and cannot be imported safely.');
117
114
  }
118
- if (value.version !== INVESTIGATION_TEMPLATE_VERSION) {
115
+ if (!INVESTIGATION_TEMPLATE_SUPPORTED_VERSIONS.includes(Number(value.version))) {
119
116
  throw new Error(`Expected investigation-template schema ${INVESTIGATION_TEMPLATE_VERSION}.`);
120
117
  }
121
118
  return normalizeInvestigationTemplateStore(value).templates;
122
119
  }
123
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');
124
124
  const local = normalizeInvestigationTemplateStore(localRaw).templates;
125
125
  const incoming = strictImport(incomingRaw);
126
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) {
@@ -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,