@slicedearth/whoisleuth-cli 1.47.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { CliUsageError } from "./errors.mjs";
5
+ import { cliMetaActionForInvocation } from "./command-reference.mjs";
5
6
  import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
7
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
6
8
  export const CLI_CONFIG_SCHEMA = 'whoisleuth.cli.config';
7
9
  export const CLI_CONFIG_VERSION = 1;
8
10
  export const MAX_CLI_CONFIG_BYTES = 64 * 1024;
9
11
  const SAFE_BOOLEAN_DEFAULTS = new Set(['--no-color', '--summary', '--verbose', '--fast']);
10
- const SAFE_VALUE_DEFAULTS = new Set(['--concurrency', '--observer', '--vantage']);
12
+ const SAFE_VALUE_DEFAULTS = new Set(['--concurrency', '--observer', '--palette', '--vantage']);
11
13
  const OPTION_GROUPS = Object.freeze({
12
14
  '--no-color': 'colour',
13
15
  '--summary': 'detail',
@@ -15,6 +17,7 @@ const OPTION_GROUPS = Object.freeze({
15
17
  '--fast': 'scan',
16
18
  '--concurrency': 'concurrency',
17
19
  '--observer': 'observer',
20
+ '--palette': 'colour',
18
21
  '--vantage': 'vantage',
19
22
  });
20
23
  function validProfileName(value) {
@@ -54,6 +57,9 @@ function validateProfileArguments(value) {
54
57
  if (argument === '--concurrency' && (!/^\d+$/u.test(next) || Number(next) < 1 || Number(next) > 8)) {
55
58
  throw new CliUsageError('CLI profile concurrency must be an integer from 1 to 8.');
56
59
  }
60
+ if (argument === '--palette' && !['auto', 'light', 'dark'].includes(next)) {
61
+ throw new CliUsageError('CLI profile palette must be auto, light, or dark.');
62
+ }
57
63
  groups.add(group);
58
64
  validated.push(argument, next);
59
65
  }
@@ -62,12 +68,14 @@ function validateProfileArguments(value) {
62
68
  function parseProfileDocument(input) {
63
69
  if (Buffer.byteLength(input, 'utf8') > MAX_CLI_CONFIG_BYTES)
64
70
  throw new CliUsageError('CLI configuration is limited to 64 KiB.');
71
+ const normalized = input.replace(/^\uFEFF/u, '');
65
72
  let parsed;
66
73
  try {
67
- parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
74
+ scanBoundedJson(normalized);
75
+ parsed = JSON.parse(normalized);
68
76
  }
69
77
  catch {
70
- throw new CliUsageError('CLI configuration must be valid JSON.');
78
+ throw new CliUsageError('CLI configuration must be valid bounded JSON without duplicate keys.');
71
79
  }
72
80
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
73
81
  if (root.schema !== CLI_CONFIG_SCHEMA || root.version !== CLI_CONFIG_VERSION || !root.profiles || typeof root.profiles !== 'object' || Array.isArray(root.profiles)) {
@@ -109,6 +117,8 @@ function suppliedGroups(argumentsList) {
109
117
  return groups;
110
118
  }
111
119
  export async function resolveCliProfileArguments(argv, options = {}) {
120
+ if (cliMetaActionForInvocation(argv))
121
+ return [...argv];
112
122
  // registry-scaffold owns --profile as the capability template to generate.
113
123
  // Do not reinterpret it as a global CLI-default profile.
114
124
  if (argv[0] === 'registry-scaffold') {
package/cli/discover.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
2
3
  import { CliUsageError } from "./arguments.mjs";
3
4
  const DEFAULT_DISCOVERY_TLDS = Object.freeze(['com', 'net', 'org']);
4
5
  const MAX_DISCOVERY_TLD_TEXT_LENGTH = 1024;
@@ -16,7 +17,12 @@ async function readDiscoveryDictionaryBounded(stream, limit = MAX_DISCOVERY_DICT
16
17
  throw new CliUsageError(`Discovery dictionary input is limited to ${limit} bytes.`);
17
18
  chunks.push(buffer);
18
19
  }
19
- return Buffer.concat(chunks).toString('utf8');
20
+ try {
21
+ return decodeBoundedUtf8(Buffer.concat(chunks), 'Discovery dictionary input');
22
+ }
23
+ catch (cause) {
24
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'Discovery dictionary input must contain valid UTF-8 text.');
25
+ }
20
26
  }
21
27
  function normalizeDiscoveryTlds(raw, maximum) {
22
28
  if (!Number.isSafeInteger(maximum) || maximum < 1) {
@@ -4,11 +4,12 @@ import { boundedCliInputError, CliUsageError } from "./errors.mjs";
4
4
  import { writePrivateFile } from "./output-file.mjs";
5
5
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
6
6
  import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
7
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
8
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
9
  export const CLI_DISCOVERY_OBSERVATION_SCHEMA = 'whoisleuth.cli.discovery-observation-snapshot';
8
10
  export const CLI_DISCOVERY_OBSERVATION_VERSION = 2;
9
11
  export const MAX_DISCOVERY_OBSERVATION_BYTES = 8 * 1024 * 1024;
10
12
  const MAX_DISCOVERY_OBSERVATIONS = 500;
11
- const SUPPORTED_DISCOVERY_OBSERVATION_VERSIONS = new Set([1, CLI_DISCOVERY_OBSERVATION_VERSION]);
12
13
  function record(value) {
13
14
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
15
  }
@@ -25,10 +26,10 @@ function normalizedTimestamp(value, label, optional = false) {
25
26
  if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
26
27
  throw new CliUsageError(`${label} is invalid.`);
27
28
  }
28
- const parsed = Date.parse(value);
29
- if (!Number.isFinite(parsed))
30
- throw new CliUsageError(`${label} is invalid.`);
31
- return new Date(parsed).toISOString();
29
+ const normalized = normalizeExplicitIsoTimestamp(value);
30
+ if (normalized)
31
+ return normalized;
32
+ throw new CliUsageError(`${label} is invalid.`);
32
33
  }
33
34
  function latestObservedAt(registrationObservedAt, dnsObservedAt) {
34
35
  if (!registrationObservedAt)
@@ -64,32 +65,25 @@ function normalizeDns(value) {
64
65
  hasDmarc: nullableBoolean(dns.hasDmarc),
65
66
  };
66
67
  }
67
- function normalizeObservation(value, version) {
68
+ function normalizeObservation(value) {
68
69
  const item = record(value);
69
70
  const dns = normalizeDns(item.dns);
70
71
  try {
71
- const validAttemptStates = version === 1 ? ['error', 'success'] : ['error', 'partial', 'success'];
72
72
  if (!dns
73
- || !validAttemptStates.includes(String(item.latestAttemptState))
73
+ || !['error', 'partial', 'success'].includes(String(item.latestAttemptState))
74
74
  || typeof item.availabilityState !== 'string' || !item.availabilityState || item.availabilityState.length > 40
75
75
  || /[\u0000-\u001f\u007f]/u.test(item.availabilityState)
76
76
  || typeof item.confidence !== 'string' || !item.confidence || item.confidence.length > 40
77
77
  || /[\u0000-\u001f\u007f]/u.test(item.confidence))
78
78
  return null;
79
- const legacyObservedAt = normalizedTimestamp(item.observedAt, 'Discovery observation time', version >= 2);
80
- const registrationObservedAt = version === 1
81
- ? item.availabilityState === 'unknown' ? null : legacyObservedAt
82
- : normalizedTimestamp(item.registrationObservedAt, 'Discovery registration observation time', true);
83
- const dnsObservedAt = version === 1
84
- ? ['partial', 'success'].includes(dns.status) ? legacyObservedAt : null
85
- : normalizedTimestamp(item.dnsObservedAt, 'Discovery DNS observation time', true);
79
+ const declaredObservedAt = normalizedTimestamp(item.observedAt, 'Discovery observation time', true);
80
+ const registrationObservedAt = normalizedTimestamp(item.registrationObservedAt, 'Discovery registration observation time', true);
81
+ const dnsObservedAt = normalizedTimestamp(item.dnsObservedAt, 'Discovery DNS observation time', true);
86
82
  const observedAt = latestObservedAt(registrationObservedAt, dnsObservedAt);
87
- if (version >= 2 && legacyObservedAt !== observedAt)
83
+ if (declaredObservedAt !== observedAt)
88
84
  return null;
89
- const latestRegistrationState = version === 1
90
- ? registrationObservedAt ? 'observed' : 'unavailable'
91
- : item.latestRegistrationState;
92
- const latestDnsState = version === 1 ? dns.status : item.latestDnsState;
85
+ const latestRegistrationState = item.latestRegistrationState;
86
+ const latestDnsState = item.latestDnsState;
93
87
  if (!['observed', 'unavailable'].includes(String(latestRegistrationState))
94
88
  || typeof latestDnsState !== 'string' || !latestDnsState || latestDnsState.length > 40
95
89
  || /[\u0000-\u001f\u007f]/u.test(latestDnsState))
@@ -118,28 +112,30 @@ function parseObservationSnapshot(text, expectedDigest) {
118
112
  if (Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_OBSERVATION_BYTES) {
119
113
  throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
120
114
  }
115
+ const normalizedInput = text.replace(/^\uFEFF/u, '');
121
116
  let parsed;
122
117
  try {
123
- parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
118
+ scanBoundedJson(normalizedInput);
119
+ parsed = JSON.parse(normalizedInput);
124
120
  }
125
121
  catch {
126
- throw new CliUsageError('Discovery observation snapshot must be valid JSON.');
122
+ throw new CliUsageError('Discovery observation snapshot must be valid bounded JSON without duplicate keys.');
127
123
  }
128
124
  const document = record(parsed);
129
125
  const version = typeof document.version === 'number' && Number.isSafeInteger(document.version)
130
126
  ? document.version
131
127
  : 0;
132
- if (document.schema !== CLI_DISCOVERY_OBSERVATION_SCHEMA || !SUPPORTED_DISCOVERY_OBSERVATION_VERSIONS.has(version)) {
133
- throw new CliUsageError(`Discovery observation snapshot must use ${CLI_DISCOVERY_OBSERVATION_SCHEMA} version 1 or ${CLI_DISCOVERY_OBSERVATION_VERSION}.`);
128
+ if (document.schema !== CLI_DISCOVERY_OBSERVATION_SCHEMA || version !== CLI_DISCOVERY_OBSERVATION_VERSION) {
129
+ throw new CliUsageError(`Discovery observation snapshot must use ${CLI_DISCOVERY_OBSERVATION_SCHEMA} version ${CLI_DISCOVERY_OBSERVATION_VERSION}.`);
134
130
  }
135
131
  if (document.configurationDigestSha256 !== expectedDigest) {
136
132
  throw new CliUsageError('Discovery observation snapshot does not match this candidate set, scan mode, or resolver selection.');
137
133
  }
138
- if (typeof document.generatedAt !== 'string' || !Number.isFinite(Date.parse(document.generatedAt))
139
- || !Array.isArray(document.observations) || document.observations.length > MAX_DISCOVERY_OBSERVATIONS) {
134
+ if (!Array.isArray(document.observations) || document.observations.length > MAX_DISCOVERY_OBSERVATIONS) {
140
135
  throw new CliUsageError('Discovery observation snapshot metadata is invalid.');
141
136
  }
142
- const observations = document.observations.map((item) => normalizeObservation(item, version));
137
+ const generatedAt = normalizedTimestamp(document.generatedAt, 'Discovery snapshot generation time') ?? '';
138
+ const observations = document.observations.map((item) => normalizeObservation(item));
143
139
  if (observations.some((item) => item === null) || document.observationCount !== observations.length) {
144
140
  throw new CliUsageError('Discovery observation snapshot contains invalid evidence.');
145
141
  }
@@ -150,7 +146,7 @@ function parseObservationSnapshot(text, expectedDigest) {
150
146
  return {
151
147
  schema: CLI_DISCOVERY_OBSERVATION_SCHEMA,
152
148
  version: CLI_DISCOVERY_OBSERVATION_VERSION,
153
- generatedAt: normalizedTimestamp(document.generatedAt, 'Discovery snapshot generation time') ?? '',
149
+ generatedAt,
154
150
  configurationDigestSha256: expectedDigest,
155
151
  observationCount: normalized.length,
156
152
  observations: normalized,
@@ -34,6 +34,15 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
34
34
  });
35
35
  context.setEventProgress(eventProgress);
36
36
  eventProgress.emit({ event: 'started' });
37
+ let resolverServers = [];
38
+ if (args.resolverText) {
39
+ try {
40
+ resolverServers = normalizeSelectedDnsResolvers(args.resolverText);
41
+ }
42
+ catch (error) {
43
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid DNS resolver selection'));
44
+ }
45
+ }
37
46
  const { metadata: generationMetadata, result } = await generateDiscoveryCandidates(args, dependencies, context);
38
47
  const candidates = result.candidates
39
48
  .filter((candidate) => typeof candidate.domain === 'string' && candidate.domain.length > 0)
@@ -51,15 +60,6 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
51
60
  return EXIT_CODES.SUCCESS;
52
61
  }
53
62
  const classify = dependencies.classifyQuery || classifyQuery;
54
- let resolverServers = [];
55
- if (args.resolverText) {
56
- try {
57
- resolverServers = normalizeSelectedDnsResolvers(args.resolverText);
58
- }
59
- catch (error) {
60
- throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid DNS resolver selection'));
61
- }
62
- }
63
63
  const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
64
64
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
65
65
  const checkpoint = args.checkpoint
@@ -1,9 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Buffer } from 'node:buffer';
3
+ import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
3
4
  import { runBulkLookups } from "./bulk.mjs";
4
5
  import { REGISTERED_STATES, availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
5
6
  import { cliCsvCell } from "./csv.mjs";
6
7
  import { CliUsageError } from "./errors.mjs";
8
+ import { terminalSafeJson } from "./formatters/json.mjs";
7
9
  export const CLI_DISCOVERY_SCAN_SCHEMA = 'whoisleuth.cli.discovery-scan';
8
10
  export const CLI_DISCOVERY_SCAN_ITEM_SCHEMA = 'whoisleuth.cli.discovery-scan.item';
9
11
  export const CLI_DISCOVERY_SCAN_VERSION = 1;
@@ -30,7 +32,12 @@ async function readDiscoveryScanListBounded(stream, limit = MAX_DISCOVERY_SCAN_A
30
32
  throw new CliUsageError(`Discovery scan allowlists are limited to ${limit} bytes.`);
31
33
  chunks.push(buffer);
32
34
  }
33
- return Buffer.concat(chunks).toString('utf8');
35
+ try {
36
+ return decodeBoundedUtf8(Buffer.concat(chunks), 'Discovery scan allowlist');
37
+ }
38
+ catch (cause) {
39
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'Discovery scan allowlist must contain valid UTF-8 text.');
40
+ }
34
41
  }
35
42
  function parseDiscoveryScanAllowlist(text, classifyQuery) {
36
43
  if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_SCAN_ALLOWLIST_BYTES) {
@@ -232,7 +239,10 @@ function buildDiscoveryScanDocument(candidates, items, metadata, allowlist, snap
232
239
  collection: {
233
240
  chunkSize: metadata.chunkSize,
234
241
  concurrency: metadata.concurrency,
235
- resolver: metadata.resolverServers.length ? 'analyst_selected' : 'system_default',
242
+ resolver: metadata.resolverServers.length ? 'mixed' : 'system_default',
243
+ dnsResolver: metadata.resolverServers.length ? 'analyst_selected' : 'system_default',
244
+ registryEndpointResolution: 'system_default',
245
+ serviceAddressResolution: 'system_default',
236
246
  resolverServers: [...metadata.resolverServers],
237
247
  },
238
248
  filter: metadata.filter,
@@ -258,6 +268,9 @@ function buildDiscoveryScanDocument(candidates, items, metadata, allowlist, snap
258
268
  'Failed, partial, rate-limited, and unavailable sources remain inconclusive and are not interpreted as absence or safety.',
259
269
  'The allowlist changes review priority only. It does not remove collected evidence or assert that a domain is benign.',
260
270
  'Relationship groups use exact bounded DNS observations from this run and can include common shared infrastructure.',
271
+ ...(metadata.resolverServers.length
272
+ ? ['The analyst-selected resolver applies to DNS record and delegation evidence only. Registry, WHOIS, HTTP, redirect, favicon, and TLS endpoint address resolution remains system-default and is reported separately.']
273
+ : []),
261
274
  ],
262
275
  };
263
276
  }
@@ -286,7 +299,7 @@ async function runDiscoveryScanChunks(queries, options) {
286
299
  }
287
300
  function formatDiscoveryScanJsonLines(document) {
288
301
  return document.results.length
289
- ? `${document.results.map((item) => JSON.stringify({ ...item, generatedAt: document.generatedAt })).join('\n')}\n`
302
+ ? `${document.results.map((item) => terminalSafeJson({ ...item, generatedAt: document.generatedAt })).join('\n')}\n`
290
303
  : '';
291
304
  }
292
305
  function formatDiscoveryScanCsv(document) {
@@ -311,6 +324,7 @@ function formatTerminalDiscoveryScan(document) {
311
324
  `Matched ${document.summary.matched}`,
312
325
  `Source failures ${document.summary.failed}`,
313
326
  `Relationships ${document.relationships.length}`,
327
+ `Resolution ${document.collection.resolver === 'mixed' ? 'mixed (selected DNS; system service addresses)' : 'system default'}`,
314
328
  '',
315
329
  'Review queue',
316
330
  ];
@@ -3,6 +3,8 @@ import { boundedCliInputError, CliUsageError } from "./errors.mjs";
3
3
  import { writePrivateFile } from "./output-file.mjs";
4
4
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
5
5
  import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
6
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
6
8
  export const CLI_DISCOVERY_SNAPSHOT_SCHEMA = 'whoisleuth.cli.discovery-snapshot';
7
9
  export const CLI_DISCOVERY_SNAPSHOT_VERSION = 1;
8
10
  export const MAX_DISCOVERY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
@@ -30,16 +32,24 @@ function normalizedDomains(value) {
30
32
  }
31
33
  return [...output].sort();
32
34
  }
35
+ function normalizedTimestamp(value) {
36
+ const normalized = normalizeExplicitIsoTimestamp(value);
37
+ if (!normalized)
38
+ throw new CliUsageError('Discovery snapshot collection time is invalid.');
39
+ return normalized;
40
+ }
33
41
  function parseDiscoverySnapshot(text, expectedConfigurationDigest) {
34
42
  if (Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_SNAPSHOT_BYTES) {
35
43
  throw new CliUsageError(`Discovery snapshot input is limited to ${MAX_DISCOVERY_SNAPSHOT_BYTES} bytes.`);
36
44
  }
45
+ const normalized = text.replace(/^\uFEFF/u, '');
37
46
  let parsed;
38
47
  try {
39
- parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
48
+ scanBoundedJson(normalized);
49
+ parsed = JSON.parse(normalized);
40
50
  }
41
51
  catch {
42
- throw new CliUsageError('Discovery snapshot must be valid JSON.');
52
+ throw new CliUsageError('Discovery snapshot must be valid bounded JSON without duplicate keys.');
43
53
  }
44
54
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
45
55
  throw new CliUsageError('Discovery snapshot must be one JSON object.');
@@ -51,16 +61,14 @@ function parseDiscoverySnapshot(text, expectedConfigurationDigest) {
51
61
  if (value.configurationDigestSha256 !== expectedConfigurationDigest) {
52
62
  throw new CliUsageError('Discovery snapshot configuration does not match this run. Choose another state file or restore the original discovery controls.');
53
63
  }
54
- if (typeof value.generatedAt !== 'string' || !Number.isFinite(Date.parse(value.generatedAt))) {
55
- throw new CliUsageError('Discovery snapshot collection time is invalid.');
56
- }
64
+ const generatedAt = normalizedTimestamp(value.generatedAt);
57
65
  const candidates = normalizedDomains(value.candidates);
58
66
  if (value.candidateCount !== candidates.length)
59
67
  throw new CliUsageError('Discovery snapshot candidate count is inconsistent.');
60
68
  return {
61
69
  schema: CLI_DISCOVERY_SNAPSHOT_SCHEMA,
62
70
  version: CLI_DISCOVERY_SNAPSHOT_VERSION,
63
- generatedAt: new Date(value.generatedAt).toISOString(),
71
+ generatedAt,
64
72
  configurationDigestSha256: expectedConfigurationDigest,
65
73
  candidateCount: candidates.length,
66
74
  candidates,
@@ -95,7 +103,7 @@ async function updateDiscoverySnapshot(path, candidatesValue, configuration, gen
95
103
  const document = {
96
104
  schema: CLI_DISCOVERY_SNAPSHOT_SCHEMA,
97
105
  version: CLI_DISCOVERY_SNAPSHOT_VERSION,
98
- generatedAt: new Date(generatedAt).toISOString(),
106
+ generatedAt: normalizedTimestamp(generatedAt),
99
107
  configurationDigestSha256: digest,
100
108
  candidateCount: candidates.length,
101
109
  candidates,