@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,375 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
4
+ import { BULK_SESSION_SCHEMA, MAX_BULK_SESSIONS, MAX_BULK_SESSION_ROWS, SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS, mergeBulkSessions, normalizeBulkSession, } from "../packages/workspace/bulk-session-model.mjs";
5
+ import { buildBulkComparisonCandidates } from "../packages/comparison/comparison-ledger-bulk.mjs";
6
+ import { MAX_COMPARISON_LEDGER_DETAIL_ROWS, boundedComparisonLedgerLimitations, comparisonLedgerCollector, comparisonLedgerValuePresent, freshComparisonLedgerCounters, makeComparisonLedgerCandidate, normaliseComparisonLedgerRowWithCounters, stableComparisonLedgerId, stableComparisonLedgerJson, } from "../packages/comparison/comparison-ledger-contract.mjs";
7
+ import { DOMAIN_PORTFOLIO_INPUT_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_SCHEMA, DOMAIN_PORTFOLIO_REVIEW_VERSION, reviewDomainPortfolio, } from "../lib/domain-portfolio-review.mjs";
8
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
9
+ import { CliUsageError } from "./errors.mjs";
10
+ import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
11
+ import { SAVED_LOOKUP_SCHEMA } from "./saved-lookup.mjs";
12
+ import { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES, } from "../packages/contracts/offline-comparison.mjs";
13
+ export { CLI_COMPARISON_LEDGER_SCHEMA, CLI_COMPARISON_LEDGER_VERSION, MAX_RETAINED_ARTIFACT_DIFF_BYTES };
14
+ const BULK_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'sessions', 'limitations']);
15
+ const PORTFOLIO_ROOT_KEYS = new Set(['schema', 'version', 'generatedAt', 'portfolioLabel', 'assets', 'simulations', 'renewalQueue', 'recoveryCycles', 'unknownCounts', 'limitations']);
16
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
17
+ function record(value) {
18
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
19
+ }
20
+ function exactKeys(value, allowed, label) {
21
+ if (Object.keys(value).some((key) => !allowed.has(key)) || [...allowed].some((key) => !Object.hasOwn(value, key))) {
22
+ throw new CliUsageError(`${label} does not match its exact retained export contract.`);
23
+ }
24
+ }
25
+ function timestamp(value, label) {
26
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
27
+ throw new CliUsageError(`${label} is missing or invalid.`);
28
+ }
29
+ const parsed = Date.parse(value);
30
+ if (!Number.isFinite(parsed))
31
+ throw new CliUsageError(`${label} is missing or invalid.`);
32
+ return new Date(parsed).toISOString();
33
+ }
34
+ function digest(value) {
35
+ return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
36
+ }
37
+ function parseDocument(raw, label) {
38
+ try {
39
+ return parseBoundedJsonObject(raw, { label, maximumBytes: MAX_RETAINED_ARTIFACT_DIFF_BYTES });
40
+ }
41
+ catch (error) {
42
+ throw new CliUsageError(error instanceof Error ? error.message : `${label} is invalid.`);
43
+ }
44
+ }
45
+ function assertLimitations(value, label) {
46
+ if (!Array.isArray(value) || value.length > 8 || value.some((item) => (typeof item !== 'string' || item.length > 600 || /[\u0000-\u001f\u007f]/u.test(item))))
47
+ throw new CliUsageError(`${label} limitations are invalid.`);
48
+ }
49
+ function parseBulkExport(root) {
50
+ exactKeys(root, BULK_ROOT_KEYS, 'Bulk session export');
51
+ const version = Number(root.version);
52
+ if (root.schema !== BULK_SESSION_SCHEMA || !SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS.includes(version)) {
53
+ throw new CliUsageError(`Bulk session exports must use a supported ${BULK_SESSION_SCHEMA} version.`);
54
+ }
55
+ const generatedAt = timestamp(root.generatedAt, 'Bulk session export generatedAt');
56
+ assertLimitations(root.limitations, 'Bulk session export');
57
+ if (!Array.isArray(root.sessions) || root.sessions.length < 1 || root.sessions.length > MAX_BULK_SESSIONS) {
58
+ throw new CliUsageError(`Bulk session exports used by diff must contain from 1 to ${MAX_BULK_SESSIONS} sessions.`);
59
+ }
60
+ const ids = new Set();
61
+ for (const [index, candidate] of root.sessions.entries()) {
62
+ const rawSession = record(candidate);
63
+ const rawResults = Array.isArray(rawSession?.results) ? rawSession.results : null;
64
+ const rawDomains = Array.isArray(rawSession?.domains) ? rawSession.domains : null;
65
+ const session = normalizeBulkSession(candidate);
66
+ if (!session || !rawResults || !rawDomains
67
+ || rawResults.length > MAX_BULK_SESSION_ROWS
68
+ || rawResults.length !== session.results.length
69
+ || rawDomains.length !== session.domains.length
70
+ || ids.has(session.id)) {
71
+ throw new CliUsageError(`Bulk session export row ${index + 1} is malformed, duplicated, or would be silently omitted.`);
72
+ }
73
+ ids.add(session.id);
74
+ }
75
+ const imported = mergeBulkSessions([], root);
76
+ if (imported.skipped || imported.pruned || imported.sessions.length !== root.sessions.length) {
77
+ throw new CliUsageError('Bulk session export cannot be compared without omitting or pruning retained sessions.');
78
+ }
79
+ return Object.freeze({ version, generatedAt, sessions: Object.freeze(imported.sessions) });
80
+ }
81
+ function selectSession(document, requested, side) {
82
+ if (requested !== null && requested !== undefined && !SAFE_SESSION_ID_RE.test(requested)) {
83
+ throw new CliUsageError(`The ${side} saved-session ID is invalid.`);
84
+ }
85
+ if (!requested && document.sessions.length !== 1) {
86
+ throw new CliUsageError(`The ${side} Bulk export contains multiple sessions; select one with --${side}-session.`);
87
+ }
88
+ const selected = requested
89
+ ? document.sessions.find((session) => session.id === requested)
90
+ : document.sessions[0];
91
+ if (!selected)
92
+ throw new CliUsageError(`The selected ${side} saved session was not found.`);
93
+ return selected;
94
+ }
95
+ function withoutOwnerHref(index) {
96
+ return Object.freeze({
97
+ ...index,
98
+ items: Object.freeze(index.items.map((item) => Object.freeze({ ...item, ownerHref: '' }))),
99
+ });
100
+ }
101
+ function withoutDetailOwnerHref(details) {
102
+ return Object.freeze({
103
+ ...details,
104
+ selectedItems: Object.freeze(details.selectedItems.map((item) => Object.freeze({ ...item, ownerHref: '' }))),
105
+ });
106
+ }
107
+ function buildBulkLedger(leftRaw, rightRaw, leftRoot, rightRoot, options, generatedAt) {
108
+ const leftDocument = parseBulkExport(leftRoot);
109
+ const rightDocument = parseBulkExport(rightRoot);
110
+ const left = selectSession(leftDocument, options.leftSessionId, 'left');
111
+ const right = selectSession(rightDocument, options.rightSessionId, 'right');
112
+ if (left.updatedAt > right.updatedAt)
113
+ throw new CliUsageError('Bulk diff requires the left saved session to be no later than the right saved session.');
114
+ const leftTransientId = stableComparisonLedgerId('cli-bulk-left', [left.id, left.updatedAt, digest(leftRaw)]);
115
+ const rightTransientId = stableComparisonLedgerId('cli-bulk-right', [right.id, right.updatedAt, digest(rightRaw)]);
116
+ const transientLeft = { ...left, id: leftTransientId };
117
+ const transientRight = { ...right, id: rightTransientId };
118
+ const counters = freshComparisonLedgerCounters();
119
+ const candidates = buildBulkComparisonCandidates([transientLeft, transientRight], [{ earlierSessionId: leftTransientId, laterSessionId: rightTransientId }], counters);
120
+ const candidate = candidates[0];
121
+ if (!candidate || candidates.length !== 1)
122
+ throw new CliUsageError('The selected Bulk sessions are not a compatible chronological pair.');
123
+ const projected = projectSingleCandidate(candidate, counters);
124
+ return Object.freeze({
125
+ schema: CLI_COMPARISON_LEDGER_SCHEMA,
126
+ version: CLI_COMPARISON_LEDGER_VERSION,
127
+ generatedAt: timestamp(generatedAt, 'Comparison ledger generatedAt'),
128
+ artifactFamily: 'bulk_sessions',
129
+ left: Object.freeze({ schema: BULK_SESSION_SCHEMA, version: leftDocument.version, id: left.id, label: left.name, retainedAt: left.updatedAt }),
130
+ right: Object.freeze({ schema: BULK_SESSION_SCHEMA, version: rightDocument.version, id: right.id, label: right.name, retainedAt: right.updatedAt }),
131
+ index: withoutOwnerHref(projected.index),
132
+ details: withoutDetailOwnerHref(projected.details),
133
+ limitations: Object.freeze([
134
+ 'The ledger compares one explicit chronological pair of compact retained Bulk sessions without making a request or reading source file paths.',
135
+ 'Portable session claims are treated as unauthenticated imports: profile-derived trust, matches, and Risk remain quarantined while independent retained source fields can still be reviewed.',
136
+ 'Missing later rows are not domain removals, releases, resolution changes, or availability findings.',
137
+ ]),
138
+ });
139
+ }
140
+ function parsePortfolioReview(root) {
141
+ exactKeys(root, PORTFOLIO_ROOT_KEYS, 'Domain portfolio review');
142
+ if (root.schema !== DOMAIN_PORTFOLIO_REVIEW_SCHEMA || root.version !== DOMAIN_PORTFOLIO_REVIEW_VERSION) {
143
+ throw new CliUsageError(`Domain portfolio reviews must use ${DOMAIN_PORTFOLIO_REVIEW_SCHEMA} version ${DOMAIN_PORTFOLIO_REVIEW_VERSION}.`);
144
+ }
145
+ const rebuilt = reviewDomainPortfolio({
146
+ schema: DOMAIN_PORTFOLIO_INPUT_SCHEMA,
147
+ version: 1,
148
+ portfolioLabel: root.portfolioLabel,
149
+ assets: root.assets,
150
+ }, timestamp(root.generatedAt, 'Domain portfolio review generatedAt'));
151
+ if (canonicalArtifactJsonV2(rebuilt) !== canonicalArtifactJsonV2(root)) {
152
+ throw new CliUsageError('Domain portfolio review derived fields do not match its bounded source asset projection.');
153
+ }
154
+ return rebuilt;
155
+ }
156
+ const PORTFOLIO_FIELDS = Object.freeze([
157
+ ['Criticality', 'portfolio', (item) => item.criticality],
158
+ ['Registrar', 'registration', (item) => item.registrar],
159
+ ['Registrar account label', 'registration', (item) => item.registrarAccount],
160
+ ['Expiry', 'registration', (item) => item.expiresAt],
161
+ ['Auto-renew', 'registration', (item) => item.autoRenew],
162
+ ['DNS providers', 'dns', (item) => item.dnsProviders],
163
+ ['Mail providers', 'mail', (item) => item.mailProviders],
164
+ ['Certificate providers', 'certificate', (item) => item.certificateProviders],
165
+ ['Recovery domains', 'recovery', (item) => item.recoveryDomains],
166
+ ]);
167
+ function portfolioSide(report, value, observedAt = null) {
168
+ return {
169
+ source: `Retained domain portfolio · ${report.portfolioLabel}`,
170
+ sourceState: 'analyst_supplied',
171
+ value,
172
+ observedAt,
173
+ retainedAt: report.generatedAt,
174
+ };
175
+ }
176
+ function portfolioRows(ownerId, earlier, later) {
177
+ const output = comparisonLedgerCollector();
178
+ let incomplete = false;
179
+ const before = new Map(earlier.assets.map((asset) => [asset.domain, asset]));
180
+ const after = new Map(later.assets.map((asset) => [asset.domain, asset]));
181
+ for (const domain of [...new Set([...before.keys(), ...after.keys()])].sort()) {
182
+ const left = before.get(domain);
183
+ const right = after.get(domain);
184
+ if (!left && right) {
185
+ output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'membership', state: 'added', field: 'Retained portfolio membership', family: 'membership', earlier: portfolioSide(earlier, null), later: portfolioSide(later, 'Included', right.reviewedAt), completeness: 'complete', limitations: ['Membership applies only to the two analyst-supplied retained portfolios; it is not evidence of registration, ownership, control, or activity.'] });
186
+ continue;
187
+ }
188
+ if (left && !right) {
189
+ incomplete = true;
190
+ output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'membership', state: 'not_compared', field: 'Retained portfolio membership', family: 'membership', earlier: portfolioSide(earlier, 'Included', left.reviewedAt), later: portfolioSide(later, null), completeness: 'partial', limitations: ['The later retained portfolio omits this asset. That omission is not evidence of registration removal, release, loss of control, or inactivity.'] });
191
+ continue;
192
+ }
193
+ if (!left || !right)
194
+ continue;
195
+ const leftRecord = left;
196
+ const rightRecord = right;
197
+ for (const [field, family, read] of PORTFOLIO_FIELDS) {
198
+ const beforeValue = read(leftRecord);
199
+ const afterValue = read(rightRecord);
200
+ if (stableComparisonLedgerJson(beforeValue) === stableComparisonLedgerJson(afterValue))
201
+ continue;
202
+ const afterPresent = comparisonLedgerValuePresent(afterValue);
203
+ const beforePresent = comparisonLedgerValuePresent(beforeValue);
204
+ if (beforePresent && !afterPresent)
205
+ incomplete = true;
206
+ output.add({
207
+ comparisonId: ownerId,
208
+ ownerId,
209
+ entityId: domain,
210
+ mode: 'temporal',
211
+ state: !beforePresent && afterPresent ? 'added' : beforePresent && !afterPresent ? 'incomplete' : 'different',
212
+ field,
213
+ family,
214
+ earlier: portfolioSide(earlier, beforeValue, left.reviewedAt),
215
+ later: portfolioSide(later, afterValue, right.reviewedAt),
216
+ completeness: beforePresent && !afterPresent ? 'partial' : 'complete',
217
+ limitations: ['Values are bounded analyst-supplied portfolio assertions, not current provider observations or proof of ownership, configuration, or responsibility.'],
218
+ });
219
+ }
220
+ if (left.reviewedAt !== right.reviewedAt) {
221
+ output.add({ comparisonId: ownerId, ownerId, entityId: domain, mode: 'temporal', state: 'collection_changed', field: 'Analyst review time', family: 'collection', earlier: portfolioSide(earlier, left.reviewedAt, left.reviewedAt), later: portfolioSide(later, right.reviewedAt, right.reviewedAt), completeness: 'complete', limitations: ['Review-time movement records analyst retention context and is not a change to the domain or provider.'] });
222
+ }
223
+ }
224
+ if (!output.totalRows) {
225
+ output.add({ comparisonId: ownerId, ownerId, entityId: 'portfolio-summary', mode: 'temporal', state: 'equivalent', field: 'Bounded retained portfolio assertions', family: 'summary', earlier: portfolioSide(earlier, 'No bounded material difference'), later: portfolioSide(later, 'No bounded material difference'), completeness: 'complete', limitations: ['Equivalence applies only to the exact bounded analyst-supplied fields retained in both portfolio review documents.'] });
226
+ }
227
+ return Object.freeze({
228
+ projection: Object.freeze({ rows: output.rows, totalRows: output.totalRows, sourceOmittedRows: 0 }),
229
+ incomplete,
230
+ });
231
+ }
232
+ function projectSingleCandidate(candidate, counters) {
233
+ const indexOmissions = Object.freeze({
234
+ inputRecords: counters.inputRecords,
235
+ inputScanTruncations: counters.inputScanTruncations,
236
+ invalidRecords: counters.invalidRecords,
237
+ duplicateRecords: counters.duplicateRecords,
238
+ indexItems: 0,
239
+ limitations: counters.limitations,
240
+ truncatedStrings: counters.truncatedStrings,
241
+ });
242
+ const index = Object.freeze({ items: Object.freeze([candidate.item]), counts: Object.freeze({ candidates: 1, retained: 1 }), omissions: indexOmissions, truncated: candidate.item.truncated || Object.values(indexOmissions).some((count) => count > 0) });
243
+ const projection = candidate.buildDetails();
244
+ const rows = [];
245
+ const ids = new Set();
246
+ let invalidRows = 0;
247
+ let duplicateRows = 0;
248
+ for (const rawRow of projection.rows) {
249
+ const row = normaliseComparisonLedgerRowWithCounters(rawRow, counters);
250
+ if (!row) {
251
+ invalidRows += 1;
252
+ continue;
253
+ }
254
+ if (ids.has(row.id)) {
255
+ duplicateRows += 1;
256
+ continue;
257
+ }
258
+ ids.add(row.id);
259
+ if (rows.length < MAX_COMPARISON_LEDGER_DETAIL_ROWS)
260
+ rows.push(row);
261
+ }
262
+ rows.sort((left, right) => left.entityId.localeCompare(right.entityId) || left.family.localeCompare(right.family) || left.field.localeCompare(right.field) || left.id.localeCompare(right.id));
263
+ const totalRows = Math.max(0, projection.totalRows - duplicateRows);
264
+ const limitations = boundedComparisonLedgerLimitations(candidate.item.limitations, counters);
265
+ const omissions = Object.freeze({
266
+ inputRecords: counters.inputRecords,
267
+ inputScanTruncations: counters.inputScanTruncations,
268
+ invalidRecords: counters.invalidRecords + invalidRows,
269
+ duplicateRecords: counters.duplicateRecords,
270
+ indexItems: 0,
271
+ entityRequests: 0,
272
+ duplicateEntityRequests: 0,
273
+ invalidEntityRequests: 0,
274
+ missingEntities: 0,
275
+ duplicateDetailRows: duplicateRows,
276
+ detailRows: Math.max(0, totalRows - rows.length),
277
+ sourceRows: projection.sourceOmittedRows,
278
+ limitations: counters.limitations,
279
+ truncatedStrings: counters.truncatedStrings,
280
+ });
281
+ const details = Object.freeze({ selectedItems: Object.freeze([candidate.item]), rows: Object.freeze(rows), totalRows, limitations: limitations.values, omissions, truncated: candidate.item.truncated || Object.values(omissions).some((count) => count > 0) });
282
+ return Object.freeze({ index, details });
283
+ }
284
+ function buildPortfolioLedger(leftRaw, rightRaw, leftRoot, rightRoot, generatedAt) {
285
+ const left = parsePortfolioReview(leftRoot);
286
+ const right = parsePortfolioReview(rightRoot);
287
+ if (left.generatedAt > right.generatedAt)
288
+ throw new CliUsageError('Domain portfolio diff requires the left review to be no later than the right review.');
289
+ const counters = freshComparisonLedgerCounters();
290
+ const ownerId = stableComparisonLedgerId('retained-portfolio-pair', [digest(leftRaw), digest(rightRaw)]);
291
+ const portfolioProjection = portfolioRows(ownerId, left, right);
292
+ const { projection } = portfolioProjection;
293
+ const completeness = portfolioProjection.incomplete ? 'partial' : 'complete';
294
+ const projectionTruncated = projection.totalRows > projection.rows.length;
295
+ const candidate = makeComparisonLedgerCandidate({
296
+ idParts: [digest(leftRaw), digest(rightRaw)],
297
+ ownerType: 'retained_artifact_pair',
298
+ ownerId,
299
+ entityId: 'domain-portfolio-pair',
300
+ label: `${left.portfolioLabel} → ${right.portfolioLabel} · retained portfolio pair`,
301
+ mode: 'temporal',
302
+ earlier: portfolioSide(left, null),
303
+ later: portfolioSide(right, null),
304
+ completeness,
305
+ truncated: projectionTruncated,
306
+ limitations: [
307
+ 'This offline ledger compares bounded retained analyst-supplied portfolio assertions and makes no provider request.',
308
+ ...(completeness === 'partial' ? ['At least one later omission is not represented as a real-world removal or resolved dependency.'] : []),
309
+ ],
310
+ ownerHref: '',
311
+ buildDetails: () => projection,
312
+ }, counters);
313
+ if (!candidate)
314
+ throw new CliUsageError('The retained domain portfolio pair could not be projected safely.');
315
+ const projected = projectSingleCandidate(candidate, counters);
316
+ return Object.freeze({
317
+ schema: CLI_COMPARISON_LEDGER_SCHEMA,
318
+ version: CLI_COMPARISON_LEDGER_VERSION,
319
+ generatedAt: timestamp(generatedAt, 'Comparison ledger generatedAt'),
320
+ artifactFamily: 'domain_portfolio',
321
+ left: Object.freeze({ schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA, version: DOMAIN_PORTFOLIO_REVIEW_VERSION, id: digest(leftRaw), label: left.portfolioLabel, retainedAt: left.generatedAt }),
322
+ right: Object.freeze({ schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA, version: DOMAIN_PORTFOLIO_REVIEW_VERSION, id: digest(rightRaw), label: right.portfolioLabel, retainedAt: right.generatedAt }),
323
+ ...projected,
324
+ limitations: Object.freeze([
325
+ 'Only the two explicitly selected retained review documents are compared; source file paths are not retained.',
326
+ 'Portfolio values are analyst-supplied context. They do not prove live configuration, registration, ownership, control, availability, or provider responsibility.',
327
+ ]),
328
+ });
329
+ }
330
+ export function buildCliRetainedArtifactDiff(leftRaw, rightRaw, options = {}, generatedAt = new Date().toISOString()) {
331
+ if (Buffer.byteLength(leftRaw, 'utf8') + Buffer.byteLength(rightRaw, 'utf8') > MAX_RETAINED_ARTIFACT_DIFF_BYTES * 2) {
332
+ throw new CliUsageError(`Retained diff input is limited to ${MAX_RETAINED_ARTIFACT_DIFF_BYTES * 2} bytes in total.`);
333
+ }
334
+ const left = parseDocument(leftRaw, 'Left retained diff input');
335
+ const right = parseDocument(rightRaw, 'Right retained diff input');
336
+ if (left.schema !== right.schema)
337
+ throw new CliUsageError('diff requires two retained documents from the same supported artifact family.');
338
+ if (left.schema === SAVED_LOOKUP_SCHEMA) {
339
+ if (options.leftSessionId || options.rightSessionId)
340
+ throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
341
+ return buildCliLookupDiff(leftRaw, rightRaw, generatedAt);
342
+ }
343
+ if (left.schema === BULK_SESSION_SCHEMA)
344
+ return buildBulkLedger(leftRaw, rightRaw, left, right, options, generatedAt);
345
+ if (left.schema === DOMAIN_PORTFOLIO_REVIEW_SCHEMA) {
346
+ if (options.leftSessionId || options.rightSessionId)
347
+ throw new CliUsageError('Saved-session selectors apply only to Bulk session exports.');
348
+ return buildPortfolioLedger(leftRaw, rightRaw, left, right, generatedAt);
349
+ }
350
+ throw new CliUsageError('diff supports saved Lookups, Bulk session exports, or retained domain portfolio reviews.');
351
+ }
352
+ export function formatCliRetainedArtifactDiff(document) {
353
+ if (document.schema !== CLI_COMPARISON_LEDGER_SCHEMA)
354
+ return formatCliLookupDiff(document);
355
+ const lines = [
356
+ 'Retained artifact comparison ledger',
357
+ `Family ${document.artifactFamily.replaceAll('_', ' ')}`,
358
+ `Left ${document.left.label} · ${document.left.id}`,
359
+ `Right ${document.right.label} · ${document.right.id}`,
360
+ `Candidates ${document.index.counts.retained}`,
361
+ `Exact rows ${document.details.rows.length} of ${document.details.totalRows}`,
362
+ '',
363
+ ];
364
+ for (const row of document.details.rows) {
365
+ lines.push(`${row.entityId} · ${row.field} [${row.state.replaceAll('_', ' ')}]`);
366
+ lines.push(` Earlier: ${row.earlier.value ?? 'not retained'} · ${row.earlier.sourceState}`);
367
+ lines.push(` Later: ${row.later.value ?? 'not retained'} · ${row.later.sourceState}`);
368
+ }
369
+ if (document.details.omissions.detailRows)
370
+ lines.push(`Omitted exact rows: ${document.details.omissions.detailRows}`);
371
+ lines.push('', 'Limitations:');
372
+ for (const limitation of document.limitations)
373
+ lines.push(` - ${limitation}`);
374
+ return `${lines.join('\n')}\n`;
375
+ }
@@ -1,21 +1,16 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
2
3
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
4
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
5
+ import { canonicalRegistrableDomain } from "../lib/registrable-domain.mjs";
3
6
  import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
7
+ import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../lib/risk-calibration-summary.mjs";
8
+ import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
4
9
  import { CliUsageError } from "./arguments.mjs";
5
- import { RISK_MUTATION_TYPES, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
6
- export const RISK_CALIBRATION_DATASET_SCHEMA = 'whoisleuth.risk-calibration-dataset';
7
- export const RISK_CALIBRATION_DATASET_VERSION = 2;
8
- export const SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS = Object.freeze([1, RISK_CALIBRATION_DATASET_VERSION]);
9
- export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
10
- export const RISK_CALIBRATION_REPORT_VERSION = 2;
11
- export const MAX_RISK_CALIBRATION_INPUT_BYTES = 2 * 1024 * 1024;
12
- export const MAX_RISK_CALIBRATION_RECORDS = 500;
13
- export const MAX_RISK_CALIBRATION_STRING_LENGTH = 256;
14
- export const RISK_CALIBRATION_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((a, b) => a - b));
15
- const MAX_MUTATIONS = 30;
16
- const MAX_PROVIDERS = 10;
17
- const MAX_FINDINGS_PER_PROVIDER = 100;
18
- const MAX_TIMESTAMP_LENGTH = 64;
10
+ import { RISK_MUTATION_TYPES } from "../lib/risk-scoring.mjs";
11
+ import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../lib/threat-intelligence-types.mjs";
12
+ export { MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_STRING_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
13
+ export const RISK_CALIBRATION_THRESHOLDS = RISK_CALIBRATION_SUMMARY_THRESHOLDS;
19
14
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
20
15
  const DISPOSITIONS = new Set([
21
16
  'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
@@ -48,7 +43,7 @@ function boundedString(value, field, maximum = MAX_RISK_CALIBRATION_STRING_LENGT
48
43
  function optionalTimestamp(value, field) {
49
44
  if (value === null || value === undefined)
50
45
  return undefined;
51
- return boundedString(value, field, MAX_TIMESTAMP_LENGTH);
46
+ return boundedString(value, field, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH);
52
47
  }
53
48
  function projectThreatIntelligence(value, field) {
54
49
  if (value === null || value === undefined)
@@ -56,8 +51,8 @@ function projectThreatIntelligence(value, field) {
56
51
  const envelope = object(value, field);
57
52
  if (!Array.isArray(envelope.providers))
58
53
  throw new CliUsageError(`${field}.providers must be an array.`);
59
- if (envelope.providers.length > MAX_PROVIDERS)
60
- throw new CliUsageError(`${field}.providers exceeds the ${MAX_PROVIDERS}-provider limit.`);
54
+ if (envelope.providers.length > MAX_RISK_CALIBRATION_PROVIDERS)
55
+ throw new CliUsageError(`${field}.providers exceeds the ${MAX_RISK_CALIBRATION_PROVIDERS}-provider limit.`);
61
56
  return {
62
57
  providers: envelope.providers.map((item, providerIndex) => {
63
58
  const prefix = `${field}.providers[${providerIndex}]`;
@@ -68,15 +63,15 @@ function projectThreatIntelligence(value, field) {
68
63
  : object(provider.observation, `${prefix}.observation`);
69
64
  if (!Array.isArray(provider.findings))
70
65
  throw new CliUsageError(`${prefix}.findings must be an array.`);
71
- if (provider.findings.length > MAX_FINDINGS_PER_PROVIDER) {
72
- throw new CliUsageError(`${prefix}.findings exceeds the ${MAX_FINDINGS_PER_PROVIDER}-finding limit.`);
66
+ if (provider.findings.length > MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER) {
67
+ throw new CliUsageError(`${prefix}.findings exceeds the ${MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER}-finding limit.`);
73
68
  }
74
69
  const observedAt = observation
75
70
  ? optionalTimestamp(observation.observedAt, `${prefix}.observation.observedAt`)
76
71
  : undefined;
77
72
  return {
78
- provider: { id: boundedString(identity.id, `${prefix}.provider.id`, 64) },
79
- state: boundedString(provider.state, `${prefix}.state`, 32),
73
+ provider: { id: boundedString(identity.id, `${prefix}.provider.id`, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH) },
74
+ state: boundedString(provider.state, `${prefix}.state`, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH),
80
75
  ...(observation ? { observation: observedAt ? { observedAt } : {} } : {}),
81
76
  findings: provider.findings.map((findingValue, findingIndex) => {
82
77
  const findingPrefix = `${prefix}.findings[${findingIndex}]`;
@@ -84,7 +79,7 @@ function projectThreatIntelligence(value, field) {
84
79
  const firstObservedAt = optionalTimestamp(finding.firstObservedAt, `${findingPrefix}.firstObservedAt`);
85
80
  const lastObservedAt = optionalTimestamp(finding.lastObservedAt, `${findingPrefix}.lastObservedAt`);
86
81
  return {
87
- category: boundedString(finding.category, `${findingPrefix}.category`, 64),
82
+ category: boundedString(finding.category, `${findingPrefix}.category`, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH),
88
83
  ...(firstObservedAt ? { firstObservedAt } : {}),
89
84
  ...(lastObservedAt ? { lastObservedAt } : {}),
90
85
  };
@@ -95,10 +90,12 @@ function projectThreatIntelligence(value, field) {
95
90
  }
96
91
  function projectEvidence(value, field) {
97
92
  const source = object(value, field);
98
- const availability = boundedString(source.availability ?? source.state, `${field}.availability`, 32);
93
+ const availability = boundedString(source.availability ?? source.state, `${field}.availability`, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH);
99
94
  if (!AVAILABILITY_STATES.has(availability))
100
95
  throw new CliUsageError(`${field}.availability is unsupported.`);
101
- const result = { availability };
96
+ const result = {
97
+ availability: availability,
98
+ };
102
99
  for (const name of BOOLEAN_FIELDS) {
103
100
  const candidate = source[name];
104
101
  if (candidate === null || candidate === undefined)
@@ -108,13 +105,13 @@ function projectEvidence(value, field) {
108
105
  result[name] = candidate;
109
106
  }
110
107
  if (source.activityStatus !== null && source.activityStatus !== undefined) {
111
- const activity = boundedString(source.activityStatus, `${field}.activityStatus`, 32);
108
+ const activity = boundedString(source.activityStatus, `${field}.activityStatus`, MAX_RISK_CALIBRATION_ACTIVITY_LENGTH);
112
109
  if (!ACTIVITY_STATES.has(activity))
113
110
  throw new CliUsageError(`${field}.activityStatus is unsupported.`);
114
111
  result.activityStatus = activity;
115
112
  }
116
113
  if (source.scanDepth !== null && source.scanDepth !== undefined) {
117
- const depth = boundedString(source.scanDepth, `${field}.scanDepth`, 16);
114
+ const depth = boundedString(source.scanDepth, `${field}.scanDepth`, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH);
118
115
  if (depth !== 'fast' && depth !== 'deep')
119
116
  throw new CliUsageError(`${field}.scanDepth must be fast or deep.`);
120
117
  Object.assign(result, { scanDepth: depth });
@@ -129,7 +126,7 @@ function projectEvidence(value, field) {
129
126
  }
130
127
  if (source.domainAgeDays !== null && source.domainAgeDays !== undefined) {
131
128
  if (typeof source.domainAgeDays !== 'number' || !Number.isFinite(source.domainAgeDays)
132
- || source.domainAgeDays < 0 || source.domainAgeDays > 100_000) {
129
+ || source.domainAgeDays < 0 || source.domainAgeDays > MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS) {
133
130
  throw new CliUsageError(`${field}.domainAgeDays must be a finite number from 0 to 100000.`);
134
131
  }
135
132
  result.domainAgeDays = source.domainAgeDays;
@@ -137,10 +134,10 @@ function projectEvidence(value, field) {
137
134
  if (source.mutationTypes !== null && source.mutationTypes !== undefined) {
138
135
  if (!Array.isArray(source.mutationTypes))
139
136
  throw new CliUsageError(`${field}.mutationTypes must be an array.`);
140
- if (source.mutationTypes.length > MAX_MUTATIONS)
141
- throw new CliUsageError(`${field}.mutationTypes exceeds the ${MAX_MUTATIONS}-item limit.`);
137
+ if (source.mutationTypes.length > MAX_RISK_CALIBRATION_MUTATIONS)
138
+ throw new CliUsageError(`${field}.mutationTypes exceeds the ${MAX_RISK_CALIBRATION_MUTATIONS}-item limit.`);
142
139
  result.mutationTypes = [...new Set(source.mutationTypes.map((item, index) => {
143
- const mutation = boundedString(item, `${field}.mutationTypes[${index}]`, 64);
140
+ const mutation = boundedString(item, `${field}.mutationTypes[${index}]`, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH);
144
141
  if (!MUTATION_TYPES.has(mutation))
145
142
  throw new CliUsageError(`${field}.mutationTypes[${index}] is unsupported.`);
146
143
  return mutation;
@@ -163,7 +160,12 @@ export async function readRiskCalibrationInputBounded(stream, limit = MAX_RISK_C
163
160
  throw new CliUsageError(`Risk calibration input is limited to ${limit} bytes.`);
164
161
  chunks.push(buffer);
165
162
  }
166
- return Buffer.concat(chunks).toString('utf8');
163
+ try {
164
+ return decodeBoundedUtf8(Buffer.concat(chunks), 'Risk calibration input');
165
+ }
166
+ catch (cause) {
167
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'Risk calibration input must contain valid UTF-8 text.');
168
+ }
167
169
  }
168
170
  export function parseRiskCalibrationDataset(text) {
169
171
  if (typeof text !== 'string')
@@ -171,12 +173,19 @@ export function parseRiskCalibrationDataset(text) {
171
173
  if (Buffer.byteLength(text, 'utf8') > MAX_RISK_CALIBRATION_INPUT_BYTES) {
172
174
  throw new CliUsageError(`Risk calibration input is limited to ${MAX_RISK_CALIBRATION_INPUT_BYTES} bytes.`);
173
175
  }
176
+ const normalized = text.replace(/^\uFEFF/u, '');
174
177
  let parsed;
175
178
  try {
176
- parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
179
+ scanBoundedJson(normalized, {
180
+ maximumDepth: MAX_RISK_CALIBRATION_JSON_DEPTH,
181
+ maximumKeys: MAX_RISK_CALIBRATION_JSON_KEYS,
182
+ maximumValues: MAX_RISK_CALIBRATION_JSON_VALUES,
183
+ maximumContainerItems: MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS,
184
+ });
185
+ parsed = JSON.parse(normalized);
177
186
  }
178
187
  catch {
179
- throw new CliUsageError('Risk calibration input must be valid JSON.');
188
+ throw new CliUsageError('Risk calibration input must be valid bounded JSON without duplicate keys.');
180
189
  }
181
190
  const document = object(parsed, 'Risk calibration input');
182
191
  const documentVersion = typeof document.version === 'number' && Number.isInteger(document.version)
@@ -197,19 +206,19 @@ export function parseRiskCalibrationDataset(text) {
197
206
  const records = document.records.map((value, index) => {
198
207
  const prefix = `records[${index}]`;
199
208
  const record = object(value, prefix);
200
- const id = boundedString(record.id, `${prefix}.id`, 128);
209
+ const id = boundedString(record.id, `${prefix}.id`, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH);
201
210
  if (ids.has(id))
202
211
  throw new CliUsageError(`${prefix}.id must be unique.`);
203
212
  ids.add(id);
204
- const domain = boundedString(record.domain, `${prefix}.domain`, 253).toLowerCase().replace(/\.$/, '');
213
+ const domain = boundedString(record.domain, `${prefix}.domain`, MAX_RISK_CALIBRATION_DOMAIN_LENGTH).toLowerCase().replace(/\.$/, '');
205
214
  if (!isValidAsciiDomainName(domain, { requireDot: true }))
206
215
  throw new CliUsageError(`${prefix}.domain must be a valid ASCII DNS hostname, not an IP address.`);
207
- const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, 32);
216
+ const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH);
208
217
  if (!DISPOSITIONS.has(analystDisposition))
209
218
  throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
210
219
  let reviewReasonCode;
211
220
  if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
212
- reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`, 64);
221
+ reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH);
213
222
  if (!REVIEW_REASON_CODES.has(reviewReasonCode))
214
223
  throw new CliUsageError(`${prefix}.reviewReasonCode is unsupported.`);
215
224
  }
@@ -296,9 +305,32 @@ function scoreBand(score) {
296
305
  return '40_69';
297
306
  return '0_39';
298
307
  }
308
+ function scoringEvidence(record) {
309
+ const threatIntelligence = record.evidence.threatIntelligence;
310
+ const scoringDomain = canonicalRegistrableDomain(record.domain);
311
+ return {
312
+ ...record.evidence,
313
+ domain: scoringDomain ?? record.domain,
314
+ ...(threatIntelligence && scoringDomain ? {
315
+ threatIntelligence: {
316
+ version: THREAT_INTELLIGENCE_ENVELOPE_VERSION,
317
+ providers: threatIntelligence.providers.map((provider) => ({
318
+ ...provider,
319
+ schema: THREAT_INTELLIGENCE_SCHEMA,
320
+ version: THREAT_INTELLIGENCE_CONTRACT_VERSION,
321
+ target: {
322
+ type: 'domain',
323
+ value: scoringDomain,
324
+ exposure: 'registrable_domain',
325
+ },
326
+ })),
327
+ },
328
+ } : {}),
329
+ };
330
+ }
299
331
  export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
300
332
  const records = dataset.records.map((record) => {
301
- const explained = explainRiskScore(record.evidence);
333
+ const explained = explainRiskScore(scoringEvidence(record));
302
334
  const classification = metricClass(record.analystDisposition);
303
335
  const includedInMetrics = classification !== 'excluded' && explained !== null;
304
336
  return {
@@ -362,7 +394,7 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
362
394
  const current = records[index];
363
395
  if (!source || !current)
364
396
  continue;
365
- const previous = options.explainPreviousRiskScore(source.evidence);
397
+ const previous = options.explainPreviousRiskScore(scoringEvidence(source));
366
398
  if (previous?.score !== current.score)
367
399
  scoresChanged += 1;
368
400
  if (scoreBand(previous?.score ?? null) !== current.band)
@@ -375,6 +407,7 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
375
407
  return {
376
408
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
377
409
  version: RISK_CALIBRATION_REPORT_VERSION,
410
+ mode: 'detailed',
378
411
  generatedAt: options.generatedAt || new Date().toISOString(),
379
412
  dataset: { schema: dataset.schema, version: dataset.version, recordCount: records.length },
380
413
  riskModelVersion: options.modelVersion,
@@ -393,10 +426,26 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
393
426
  records,
394
427
  interpretation: {
395
428
  authority: 'analyst_context_only',
396
- statement: 'This offline replay compares heuristic Risk scores with analyst dispositions. It does not prove maliciousness or safety.',
429
+ statement: RISK_CALIBRATION_SUMMARY_INTERPRETATION,
397
430
  automaticTuning: false,
398
431
  networkRequests: false,
399
432
  persisted: false,
400
433
  },
401
434
  };
402
435
  }
436
+ export function serializeRiskCalibrationReport(report) {
437
+ try {
438
+ const snapshot = snapshotRiskCalibrationReportForSerialization(report);
439
+ const serialised = serializeRiskCalibrationSnapshot(snapshot);
440
+ if (snapshot.mode === 'summary')
441
+ parseRiskCalibrationSummaryReport(serialised);
442
+ else
443
+ buildRiskCalibrationSummaryReport(snapshot.document);
444
+ return serialised;
445
+ }
446
+ catch (cause) {
447
+ throw new CliUsageError(cause instanceof Error
448
+ ? cause.message
449
+ : 'Risk calibration output must use the current detailed or summary report contract.');
450
+ }
451
+ }