@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -9,16 +9,29 @@ import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src
9
9
  import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
10
10
  import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
11
11
  import { DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
12
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION, } from "../lib/domain-change-packet.mjs";
13
+ import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, verifyInvestigationCapsule, } from "../frontend/src/lib/analysis/investigation-capsule.js";
14
+ import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, } from "./investigation-manifest.mjs";
15
+ import { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, verifyCliCasePack, } from "./case-pack.mjs";
12
16
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
13
17
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
14
18
  export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
15
19
  export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
20
+ export class UnsupportedOfflineArtifactError extends TypeError {
21
+ code = 'unsupported_artifact';
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'UnsupportedOfflineArtifactError';
25
+ }
26
+ }
16
27
  const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
17
28
  [ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([ACQUISITION_DECISION_PACKET_VERSION]),
18
29
  [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
19
30
  [BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
20
31
  [BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
21
32
  [DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_MANIFEST_VERSION]),
33
+ [DOMAIN_CHANGE_PACKET_SCHEMA]: new Set([DOMAIN_CHANGE_PACKET_VERSION]),
34
+ [INVESTIGATION_MANIFEST_SCHEMA]: new Set([INVESTIGATION_MANIFEST_VERSION]),
22
35
  });
23
36
  function record(value) {
24
37
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -27,7 +40,7 @@ function record(value) {
27
40
  }
28
41
  function parseJson(raw) {
29
42
  if (typeof raw !== 'string')
30
- throw new TypeError('Artifact input must be UTF-8 JSON text.');
43
+ throw new TypeError('Artefact input must be UTF-8 JSON text.');
31
44
  const bytes = Buffer.byteLength(raw, 'utf8');
32
45
  if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
33
46
  throw new TypeError(`Artifact input must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
@@ -37,18 +50,22 @@ function parseJson(raw) {
37
50
  parsed = JSON.parse(raw);
38
51
  }
39
52
  catch {
40
- throw new TypeError('Artifact input is not valid JSON.');
53
+ throw new TypeError('Artefact input is not valid JSON.');
41
54
  }
42
55
  const value = record(parsed);
43
56
  if (!value)
44
- throw new TypeError('Artifact input must contain one JSON object.');
57
+ throw new TypeError('Artefact input must contain one JSON object.');
45
58
  return value;
46
59
  }
47
60
  function artifactVersion(value) {
48
- if (!Number.isSafeInteger(value.version) || Number(value.version) < 1 || Number(value.version) > 1000) {
49
- throw new TypeError('Artifact version is missing or invalid.');
61
+ const declared = value.version ?? value.schemaVersion;
62
+ if (value.version !== undefined && value.schemaVersion !== undefined && value.version !== value.schemaVersion) {
63
+ throw new TypeError('Artefact version declarations do not agree.');
64
+ }
65
+ if (!Number.isSafeInteger(declared) || Number(declared) < 1 || Number(declared) > 1000) {
66
+ throw new TypeError('Artefact version is missing or invalid.');
50
67
  }
51
- return Number(value.version);
68
+ return Number(declared);
52
69
  }
53
70
  function inputBytes(raw) {
54
71
  return Buffer.byteLength(raw, 'utf8');
@@ -60,7 +77,7 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
60
77
  artifact: Object.freeze({
61
78
  kind: encrypted ? 'encrypted_workspace_archive' : 'workspace_archive',
62
79
  schema: WORKSPACE_ARCHIVE_SCHEMA,
63
- version: archive.version,
80
+ version: archive.sourceVersion,
64
81
  }),
65
82
  state: 'verified',
66
83
  valid: true,
@@ -83,18 +100,18 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
83
100
  async function verifySignedArtifact(raw, value, schema, version) {
84
101
  const supported = SIGNED_ARTIFACT_VERSIONS[schema];
85
102
  if (!supported?.has(version)) {
86
- throw new TypeError('This signed review-artifact schema or version is not supported.');
103
+ throw new UnsupportedOfflineArtifactError('This signed review-artifact schema or version is not supported.');
87
104
  }
88
105
  const integrity = record(value.integrity);
89
106
  if (!integrity
90
107
  || integrity.algorithm !== 'SHA-256'
91
108
  || typeof integrity.digestSha256 !== 'string'
92
109
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
93
- throw new TypeError('The signed review artifact has a missing or malformed integrity envelope.');
110
+ throw new TypeError('The signed review artefact has a missing or malformed integrity envelope.');
94
111
  }
95
112
  const { integrity: _integrity, ...unsigned } = value;
96
113
  if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
97
- throw new TypeError('The signed review artifact failed its SHA-256 integrity check.');
114
+ throw new TypeError('The signed review artefact failed its SHA-256 integrity check.');
98
115
  }
99
116
  if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA)
100
117
  verifyDomainControlManifest(value);
@@ -116,12 +133,41 @@ async function verifySignedArtifact(raw, value, schema, version) {
116
133
  ciphertextBytes: null,
117
134
  }),
118
135
  limitations: Object.freeze([
119
- 'Digest verification detects changes to this exported artifact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
136
+ 'Digest verification detects changes to this exported artefact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
120
137
  ]),
121
138
  });
122
139
  }
123
140
  export async function verifyOfflineArtifact(raw, options = {}) {
124
141
  const value = parseJson(raw);
142
+ const casePackPacket = record(value.packet);
143
+ if (casePackPacket?.schema === CLI_CASE_PACK_SCHEMA) {
144
+ const verified = verifyCliCasePack(value);
145
+ return Object.freeze({
146
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
147
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
148
+ artifact: Object.freeze({
149
+ kind: 'cli_case_pack',
150
+ schema: CLI_CASE_PACK_SCHEMA,
151
+ version: CLI_CASE_PACK_VERSION,
152
+ }),
153
+ state: 'verified',
154
+ valid: true,
155
+ checks: Object.freeze({
156
+ structure: 'verified',
157
+ contentIntegrity: 'verified',
158
+ authenticatedEncryption: 'not_applicable',
159
+ }),
160
+ summary: Object.freeze({
161
+ inputBytes: inputBytes(raw),
162
+ sectionCount: null,
163
+ recordCount: verified.caseCount,
164
+ ciphertextBytes: null,
165
+ }),
166
+ limitations: Object.freeze([
167
+ 'Case-pack verification detects changes after export and checks the bounded case collection; it does not authenticate the analyst, review decision, source observations, or recipient authorisation.',
168
+ ]),
169
+ });
170
+ }
125
171
  if (isEncryptedWorkspaceArchive(value)) {
126
172
  const inspected = inspectEncryptedWorkspaceArchive(value);
127
173
  if (!options.passphrase) {
@@ -163,7 +209,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
163
209
  }
164
210
  if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
165
211
  if (version !== CASE_RESPONSE_PACKET_VERSION) {
166
- throw new TypeError('This case-response packet version is not supported.');
212
+ throw new UnsupportedOfflineArtifactError('This case-response packet version is not supported.');
167
213
  }
168
214
  const integrity = record(value.integrity);
169
215
  if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
@@ -191,11 +237,41 @@ export async function verifyOfflineArtifact(raw, options = {}) {
191
237
  ]),
192
238
  });
193
239
  }
240
+ if (schema === INVESTIGATION_CAPSULE_SCHEMA) {
241
+ if (version !== INVESTIGATION_CAPSULE_VERSION) {
242
+ throw new UnsupportedOfflineArtifactError('This investigation-capsule version is not supported.');
243
+ }
244
+ const verification = await verifyInvestigationCapsule(value);
245
+ if (!verification.valid)
246
+ throw new TypeError('The investigation capsule failed its embedded projection integrity checks.');
247
+ return Object.freeze({
248
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
249
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
250
+ artifact: Object.freeze({ kind: 'investigation_capsule', schema, version }),
251
+ state: 'verified',
252
+ valid: true,
253
+ checks: Object.freeze({
254
+ structure: 'verified',
255
+ contentIntegrity: 'verified',
256
+ authenticatedEncryption: 'not_applicable',
257
+ }),
258
+ summary: Object.freeze({
259
+ inputBytes: inputBytes(raw),
260
+ sectionCount: null,
261
+ recordCount: null,
262
+ ciphertextBytes: null,
263
+ }),
264
+ limitations: Object.freeze([
265
+ 'The embedded brief, graph, and optional analyst-record projections match their declared digests; the linked Lookup evidence is not embedded and must be verified separately.',
266
+ 'Digest verification detects changed content but does not authenticate the analyst, signer, collection source, or truth of retained observations and assertions.',
267
+ ]),
268
+ });
269
+ }
194
270
  if (schema === SAVED_LOOKUP_SCHEMA) {
195
271
  if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
196
- throw new TypeError('This saved Lookup document version is not supported.');
272
+ throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
197
273
  }
198
- const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
274
+ const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artefact' });
199
275
  return Object.freeze({
200
276
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
201
277
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -222,7 +298,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
222
298
  }
223
299
  export function formatOfflineArtifactVerification(report) {
224
300
  const lines = [
225
- 'WHOISleuth offline artifact verification',
301
+ 'WHOISleuth offline artefact verification',
226
302
  `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
227
303
  `State: ${report.state}`,
228
304
  `Structure: ${report.checks.structure}`,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { open } from 'node:fs/promises';
3
+ import { recordOrNull } from "../lib/bounded-contract-normalizers.mjs";
3
4
  import { CliUsageError } from "./errors.mjs";
4
5
  import { writePrivateFile } from "./output-file.mjs";
5
6
  export const CLI_BULK_CHECKPOINT_SCHEMA = 'whoisleuth.cli.bulk-checkpoint';
@@ -10,11 +11,6 @@ const MAX_CHECKPOINT_OBJECT_KEYS = 256;
10
11
  const MAX_CHECKPOINT_ARRAY_ITEMS = 1_024;
11
12
  const MAX_CHECKPOINT_STRING_LENGTH = 32_768;
12
13
  const COMPACT_RESULT_KEYS = new Set(['availability', 'diagnostics']);
13
- function record(value) {
14
- return value && typeof value === 'object' && !Array.isArray(value)
15
- ? value
16
- : null;
17
- }
18
14
  function checkpointDigest(queries, deep) {
19
15
  return createHash('sha256')
20
16
  .update(deep ? 'deep\n' : 'fast\n')
@@ -37,7 +33,7 @@ function isBoundedCheckpointJson(value, depth = 0) {
37
33
  return value.length <= MAX_CHECKPOINT_ARRAY_ITEMS
38
34
  && value.every((item) => isBoundedCheckpointJson(item, depth + 1));
39
35
  }
40
- const object = record(value);
36
+ const object = recordOrNull(value);
41
37
  if (!object)
42
38
  return false;
43
39
  const entries = Object.entries(object);
@@ -45,18 +41,18 @@ function isBoundedCheckpointJson(value, depth = 0) {
45
41
  && entries.every(([key, item]) => key.length <= 128 && isBoundedCheckpointJson(item, depth + 1));
46
42
  }
47
43
  function normalizeCompactResult(value) {
48
- const result = record(value);
44
+ const result = recordOrNull(value);
49
45
  if (!result || !isBoundedCheckpointJson(result))
50
46
  return null;
51
47
  const keys = Object.keys(result);
52
48
  if (keys.length !== COMPACT_RESULT_KEYS.size || keys.some((key) => !COMPACT_RESULT_KEYS.has(key)))
53
49
  return null;
54
- if (!record(result.availability) || !record(result.diagnostics))
50
+ if (!recordOrNull(result.availability) || !recordOrNull(result.diagnostics))
55
51
  return null;
56
52
  return result;
57
53
  }
58
54
  function normalizeCheckpointResult(value, queries, classifyQuery) {
59
- const item = record(value);
55
+ const item = recordOrNull(value);
60
56
  const index = item?.index;
61
57
  if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= queries.length)
62
58
  return null;
@@ -72,7 +68,7 @@ function normalizeCheckpointResult(value, queries, classifyQuery) {
72
68
  if (item.ok !== true || !result)
73
69
  return null;
74
70
  const classified = classifyQuery(query);
75
- const storedClassified = record(item.classified);
71
+ const storedClassified = recordOrNull(item.classified);
76
72
  if (storedClassified?.type !== classified.type || storedClassified.value !== classified.value)
77
73
  return null;
78
74
  return { index: Number(index), query, ok: true, classified, result };
@@ -88,7 +84,7 @@ function parseBulkCheckpoint(text, options) {
88
84
  catch {
89
85
  throw new CliUsageError('Bulk checkpoint must be valid JSON.');
90
86
  }
91
- const document = record(parsed);
87
+ const document = recordOrNull(parsed);
92
88
  const expectedDigest = checkpointDigest(options.queries, options.deep);
93
89
  if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || document.version !== CLI_BULK_CHECKPOINT_VERSION) {
94
90
  throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version ${CLI_BULK_CHECKPOINT_VERSION}.`);
@@ -8,6 +8,9 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
8
8
  import EXIT_CODES from "./exit-codes.mjs";
9
9
  import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
10
10
  import { formatTerminalBulk } from "./formatters/terminal.mjs";
11
+ import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
12
+ import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
13
+ import { formatCliJunit } from "./ci-report.mjs";
11
14
  import { createCliProgressEvents } from "./progress-events.mjs";
12
15
  async function runBulkCommand(args, dependencies, context) {
13
16
  const eventProgress = createCliProgressEvents(context.stderr, {
@@ -31,6 +34,14 @@ async function runBulkCommand(args, dependencies, context) {
31
34
  throw new CliUsageError(`Could not read bulk input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
32
35
  }
33
36
  const parsed = parseBulkQueries(input, { deep: args.deep });
37
+ if (args.plan) {
38
+ const document = buildCollectionPreflight({
39
+ command: 'bulk', targetCount: parsed.queries.length, targetLimit: args.deep ? 50 : 500,
40
+ deep: args.deep, concurrency: args.concurrency, output: args.output, checkpoint: false,
41
+ });
42
+ context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
43
+ return EXIT_CODES.SUCCESS;
44
+ }
34
45
  const classify = dependencies.classifyQuery || classifyQuery;
35
46
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
36
47
  const checkpoint = args.checkpoint
@@ -82,9 +93,12 @@ async function runBulkCommand(args, dependencies, context) {
82
93
  filter: args.filter,
83
94
  };
84
95
  const selectedItems = selectBulkItems(items, args.filter);
96
+ const selectedDocument = buildCliBulkDocument(selectedItems, metadata);
85
97
  if (!args.quiet) {
86
98
  if (args.output === 'json')
87
- context.writeStdout(formatJsonDocument(buildCliBulkDocument(selectedItems, metadata)));
99
+ context.writeStdout(formatJsonDocument(selectedDocument));
100
+ else if (args.output === 'junit')
101
+ context.writeStdout(formatCliJunit(selectedDocument));
88
102
  else if (args.output === 'jsonl')
89
103
  context.writeStdout(formatJsonLines(selectedItems, metadata));
90
104
  else if (args.output === 'csv')
@@ -102,7 +116,10 @@ async function runBulkCommand(args, dependencies, context) {
102
116
  context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
103
117
  }
104
118
  }
105
- const exitCode = checkpointFailure || items.some((item) => !item.ok)
119
+ const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || []);
120
+ if (policyFindings.length && !eventProgress.enabled)
121
+ context.writeStderr(formatFailPolicyNotice(policyFindings));
122
+ const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
106
123
  ? EXIT_CODES.PARTIAL_FAILURE
107
124
  : EXIT_CODES.SUCCESS;
108
125
  eventProgress.emit({ event: 'completed', exitCode });
@@ -0,0 +1,136 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Buffer } from 'node:buffer';
3
+ import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { buildCaseReport } from "../frontend/src/lib/analysis/case-report.js";
5
+ import { CASE_IMPORT_VERSIONS, MAX_CASE_IMPORT_BYTES, CASE_SCHEMA_VERSION, normalizeCaseStore, } from "../frontend/src/lib/analysis/case-model.js";
6
+ import { CliUsageError } from "./errors.mjs";
7
+ export const CLI_CASE_PACK_SCHEMA = 'whoisleuth.cli.case-pack';
8
+ export const CLI_CASE_PACK_VERSION = 1;
9
+ export const MAX_CASE_PACK_INPUT_BYTES = 4 * 1024 * 1024;
10
+ export const MAX_CASE_PACK_CASES = 25;
11
+ function redactedCase(record, audience) {
12
+ if (audience === 'internal')
13
+ return structuredClone(record);
14
+ return {
15
+ ...structuredClone(record),
16
+ notes: [],
17
+ actions: audience === 'public' ? [] : record.actions.map((item) => ({ ...item, recipient: '[redacted]' })),
18
+ manualTrail: record.manualTrail.map((item) => ({ ...item, target: null })),
19
+ assertions: audience === 'public' ? [] : structuredClone(record.assertions),
20
+ };
21
+ }
22
+ export function buildCliCasePack(input, options, generatedAt = new Date().toISOString()) {
23
+ if (!options.reviewed)
24
+ throw new CliUsageError('case-pack requires --reviewed after the audience-specific output has been checked.');
25
+ if (Buffer.byteLength(input, 'utf8') > MAX_CASE_PACK_INPUT_BYTES)
26
+ throw new CliUsageError('Case-pack input is limited to 4 MiB.');
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
30
+ }
31
+ catch {
32
+ throw new CliUsageError('Case-pack input must be valid JSON.');
33
+ }
34
+ const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
35
+ if (typeof root.version !== 'number' || !CASE_IMPORT_VERSIONS.includes(root.version) || !Array.isArray(root.cases)) {
36
+ throw new CliUsageError(`Case-pack input must be a supported WHOISleuth case export through schema ${CASE_SCHEMA_VERSION}.`);
37
+ }
38
+ const normalised = normalizeCaseStore(root).cases;
39
+ if (!normalised.length)
40
+ throw new CliUsageError('Case-pack input did not contain a valid case.');
41
+ if (normalised.length !== root.cases.length) {
42
+ throw new CliUsageError('Case-pack input contains an invalid or duplicate case. Correct the browser export before packaging it.');
43
+ }
44
+ if (normalised.length > MAX_CASE_PACK_CASES) {
45
+ throw new CliUsageError(`Case packs are limited to ${MAX_CASE_PACK_CASES} reviewed cases. Export a smaller selected set so no case is silently omitted.`);
46
+ }
47
+ const cases = normalised.map((item) => redactedCase(item, options.audience));
48
+ const reports = cases.map((item) => buildCaseReport(item, { includeNotes: false, generatedAt }).json);
49
+ const exclusions = options.audience === 'internal'
50
+ ? ['Raw upstream payloads and credentials are outside the case schema.']
51
+ : options.audience === 'trusted'
52
+ ? ['Case notes', 'Recipient values', 'Manual trail targets', 'Raw upstream payloads and credentials']
53
+ : ['Case notes', 'Actions and recipient values', 'Analyst assertions', 'Manual trail targets', 'Raw upstream payloads and credentials'];
54
+ const packet = Object.freeze({
55
+ schema: CLI_CASE_PACK_SCHEMA,
56
+ version: CLI_CASE_PACK_VERSION,
57
+ audience: options.audience,
58
+ reviewed: true,
59
+ reports: Object.freeze(reports),
60
+ redactionManifest: Object.freeze({ excluded: Object.freeze(exclusions), sourceCaseCount: normalised.length }),
61
+ limitations: Object.freeze([
62
+ 'This local package is browser-importable through its top-level case collection and does not upload or submit evidence.',
63
+ 'The reviewed flag records a deliberate CLI choice; it does not prove recipient authorisation, factual correctness, or legal sufficiency.',
64
+ 'Importing the package does not restore fields excluded by its audience profile.',
65
+ ]),
66
+ });
67
+ const unsigned = { version: CASE_SCHEMA_VERSION, exportedAt: generatedAt, cases, packet };
68
+ const document = Object.freeze({
69
+ ...unsigned,
70
+ integrity: Object.freeze({
71
+ algorithm: 'SHA-256',
72
+ canonicalization: 'sorted-json-v1',
73
+ digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJson(unsigned)).digest('hex')}`,
74
+ }),
75
+ });
76
+ if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
77
+ throw new CliUsageError('The generated case pack exceeds the browser 2 MiB import limit. Select fewer cases or a more restrictive audience so no evidence is silently omitted.');
78
+ }
79
+ return document;
80
+ }
81
+ export function verifyCliCasePack(input) {
82
+ const root = input && typeof input === 'object' && !Array.isArray(input)
83
+ ? input
84
+ : null;
85
+ const packet = root?.packet && typeof root.packet === 'object' && !Array.isArray(root.packet)
86
+ ? root.packet
87
+ : null;
88
+ const integrity = root?.integrity && typeof root.integrity === 'object' && !Array.isArray(root.integrity)
89
+ ? root.integrity
90
+ : null;
91
+ if (!root
92
+ || !packet
93
+ || packet.schema !== CLI_CASE_PACK_SCHEMA
94
+ || packet.version !== CLI_CASE_PACK_VERSION
95
+ || packet.reviewed !== true
96
+ || !['internal', 'public', 'trusted'].includes(String(packet.audience))
97
+ || !Array.isArray(packet.reports)
98
+ || !Array.isArray(root.cases)
99
+ || root.cases.length < 1
100
+ || root.cases.length > MAX_CASE_PACK_CASES
101
+ || packet.reports.length !== root.cases.length
102
+ || typeof root.version !== 'number'
103
+ || !CASE_IMPORT_VERSIONS.includes(root.version)
104
+ || !integrity
105
+ || integrity.algorithm !== 'SHA-256'
106
+ || integrity.canonicalization !== 'sorted-json-v1'
107
+ || typeof integrity.digestSha256 !== 'string'
108
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
109
+ throw new TypeError('The CLI case pack structure or integrity envelope is invalid.');
110
+ }
111
+ if (Buffer.byteLength(JSON.stringify(root), 'utf8') > MAX_CASE_IMPORT_BYTES) {
112
+ throw new TypeError('The CLI case pack exceeds the browser import limit.');
113
+ }
114
+ const { integrity: _integrity, ...unsigned } = root;
115
+ const calculated = `sha256:${createHash('sha256').update(canonicalArtifactJson(unsigned)).digest('hex')}`;
116
+ if (calculated !== integrity.digestSha256) {
117
+ throw new TypeError('The CLI case pack failed its SHA-256 integrity check.');
118
+ }
119
+ const normalised = normalizeCaseStore(root).cases;
120
+ if (normalised.length !== root.cases.length) {
121
+ throw new TypeError('The CLI case pack contains an invalid case collection.');
122
+ }
123
+ return Object.freeze({ caseCount: normalised.length });
124
+ }
125
+ export function formatCliCasePack(document) {
126
+ return [
127
+ 'Reviewed case pack',
128
+ `Audience ${document.packet.audience}`,
129
+ `Cases ${document.cases.length}`,
130
+ `Reports ${document.packet.reports.length}`,
131
+ `Digest ${document.integrity.digestSha256}`,
132
+ '',
133
+ ...document.packet.redactionManifest.excluded.map((item) => `Excluded: ${item}`),
134
+ '',
135
+ ].join('\n');
136
+ }
@@ -0,0 +1,73 @@
1
+ import { WHOISLEUTH_REQUEST_POLICY_URL } from "../lib/project-metadata.mjs";
2
+ function record(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
4
+ }
5
+ function xml(value) {
6
+ return String(value).replace(/&/gu, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;').replace(/"/gu, '&quot;').replace(/'/gu, '&apos;');
7
+ }
8
+ function junitCases(document) {
9
+ const schema = String(document.schema || '');
10
+ if (schema === 'whoisleuth.cli.lookup') {
11
+ const diagnostics = record(document.diagnostics);
12
+ const cases = Object.entries(diagnostics).slice(0, 64).map(([source, value]) => {
13
+ const state = String(record(value).status ?? record(value).state ?? 'unavailable').toLowerCase();
14
+ return ['error', 'failed', 'partial', 'rate_limited', 'timeout', 'unavailable'].includes(state)
15
+ ? { name: `source ${source}`, failure: `Source state: ${state}` }
16
+ : { name: `source ${source}` };
17
+ });
18
+ return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
19
+ }
20
+ if (schema === 'whoisleuth.cli.bulk') {
21
+ const summary = record(document.summary);
22
+ const failed = Number(summary.failed ?? 0);
23
+ const results = Array.isArray(document.results) ? document.results.map(record) : [];
24
+ const inconclusive = results.filter((item) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(String(record(item.availability).state || '').toLowerCase())).length;
25
+ return [
26
+ failed ? { name: 'collection failures', failure: `${failed} target collection(s) failed.` } : { name: 'collection failures' },
27
+ inconclusive ? { name: 'authority conclusions', failure: `${inconclusive} target state(s) remained inconclusive.` } : { name: 'authority conclusions' },
28
+ ];
29
+ }
30
+ if (schema === 'whoisleuth.cli.domain-control-monitor') {
31
+ const collection = record(document.collection);
32
+ const summary = record(record(document.flightRecorder).summary);
33
+ const failed = Number(collection.failed ?? 0);
34
+ const drift = Number(summary.unexpectedChanges ?? 0);
35
+ return [
36
+ failed ? { name: 'domain control collection', failure: `${failed} owned-domain collection(s) failed.` } : { name: 'domain control collection' },
37
+ drift ? { name: 'unexpected domain control changes', failure: `${drift} complete observation change(s) require review.` } : { name: 'unexpected domain control changes' },
38
+ ];
39
+ }
40
+ return [{ name: 'WHOISleuth result', failure: 'Unsupported document for JUnit conversion.' }];
41
+ }
42
+ export function formatCliJunit(document) {
43
+ const cases = junitCases(record(document));
44
+ const failures = cases.filter((item) => item.failure).length;
45
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="WHOISleuth" tests="${cases.length}" failures="${failures}">\n${cases.map((item) => ` <testcase classname="whoisleuth" name="${xml(item.name)}">${item.failure ? `<failure message="${xml(item.failure)}" />` : ''}</testcase>`).join('\n')}\n</testsuite>\n`;
46
+ }
47
+ export function buildPostureSarif(document) {
48
+ const root = record(document);
49
+ const checks = Array.isArray(root.checks) ? root.checks.map(record).slice(0, 64) : [];
50
+ const actionable = checks.filter((item) => ['warning', 'danger'].includes(String(item.status).toLowerCase()));
51
+ const rules = actionable.map((item) => ({
52
+ id: `WHOISLEUTH-${String(item.id || 'posture').replace(/[^a-z0-9_-]/giu, '-').slice(0, 64)}`,
53
+ shortDescription: { text: String(item.label || 'Domain posture finding').slice(0, 200) },
54
+ help: { text: String(item.remediation || item.detail || 'Review this owned-domain posture finding.').slice(0, 1000) },
55
+ }));
56
+ return Object.freeze({
57
+ version: '2.1.0',
58
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
59
+ runs: [Object.freeze({
60
+ tool: { driver: { name: 'WHOISleuth', informationUri: WHOISLEUTH_REQUEST_POLICY_URL, rules } },
61
+ results: actionable.map((item) => ({
62
+ ruleId: `WHOISLEUTH-${String(item.id || 'posture').replace(/[^a-z0-9_-]/giu, '-').slice(0, 64)}`,
63
+ level: String(item.status).toLowerCase() === 'danger' ? 'error' : 'warning',
64
+ message: { text: `${String(item.summary || item.label || 'Posture finding').slice(0, 500)}${item.remediation ? ` Next: ${String(item.remediation).slice(0, 500)}` : ''}` },
65
+ })),
66
+ properties: {
67
+ ownedDomainConfirmed: true,
68
+ generatedAt: typeof root.generatedAt === 'string' ? root.generatedAt : null,
69
+ evidenceBoundary: 'Passive owned-domain DNS posture; raw records and contact data omitted from SARIF.',
70
+ },
71
+ })],
72
+ });
73
+ }
@@ -0,0 +1,59 @@
1
+ export const CLI_COLLECTION_PREFLIGHT_SCHEMA = 'whoisleuth.cli.collection-preflight';
2
+ export const CLI_COLLECTION_PREFLIGHT_VERSION = 1;
3
+ export function buildCollectionPreflight(options) {
4
+ const sourceFamilies = options.deep
5
+ ? ['RDAP', 'WHOIS', 'DNS', 'HTTP and static page identity', 'TLS', 'network allocation and routing context', 'configured optional threat sources']
6
+ : ['RDAP', 'conditional DNS authority evidence'];
7
+ return Object.freeze({
8
+ schema: CLI_COLLECTION_PREFLIGHT_SCHEMA,
9
+ version: CLI_COLLECTION_PREFLIGHT_VERSION,
10
+ command: options.command,
11
+ mode: options.deep ? 'deep' : 'fast',
12
+ networkRequestsMade: false,
13
+ scope: Object.freeze({
14
+ selectedTargets: options.targetCount,
15
+ commandTargetLimit: options.targetLimit,
16
+ concurrency: options.concurrency,
17
+ sourceFamilies: Object.freeze(sourceFamilies),
18
+ exactRequestCountKnown: false,
19
+ requestBoundary: 'Collector-specific timeouts, response limits, referrals, redirects, retries, registry policy and provider quotas remain enforced independently.',
20
+ }),
21
+ disclosure: Object.freeze([
22
+ 'Targets are sent only to the source families required by the selected mode and permitted by registry/provider policy.',
23
+ ...(options.customResolvers ? ['Selected public DNS resolvers receive the DNS questions needed for collection.'] : ['The runtime DNS resolver receives DNS questions when DNS collection is required.']),
24
+ ...(options.deep ? ['Target web infrastructure receives bounded homepage, favicon and TLS requests; configured optional providers can receive the documented target representation.'] : []),
25
+ ]),
26
+ persistence: Object.freeze({
27
+ output: options.output,
28
+ checkpoint: options.checkpoint,
29
+ note: options.checkpoint
30
+ ? 'A private resumable checkpoint is written locally; final output remains separate.'
31
+ : 'No checkpoint is written. Final output is emitted only to the selected destination.',
32
+ }),
33
+ review: Object.freeze({
34
+ allowlistAffectsPriorityOnly: options.allowlist === true,
35
+ enforcementActions: false,
36
+ }),
37
+ limitations: Object.freeze([
38
+ 'This offline preflight reports collection families and hard target/concurrency bounds, not an exact raw request count.',
39
+ 'Redirects, WHOIS referrals, cache state, feature configuration, policy decisions, source availability and prerequisite evidence can change the requests actually made.',
40
+ 'A planned source may still return unsupported, unavailable, partial, rate-limited or inconclusive evidence.',
41
+ ]),
42
+ });
43
+ }
44
+ export function formatCollectionPreflight(document) {
45
+ return [
46
+ `WHOISleuth ${document.command} preflight`,
47
+ `Mode ${document.mode}`,
48
+ `Selected targets ${document.scope.selectedTargets} / ${document.scope.commandTargetLimit}`,
49
+ `Concurrency ${document.scope.concurrency}`,
50
+ 'Network requests none',
51
+ '',
52
+ 'Collector families',
53
+ ...document.scope.sourceFamilies.map((source) => ` ${source}`),
54
+ '',
55
+ ...document.disclosure.map((item) => `Disclosure: ${item}`),
56
+ ...document.limitations.map((item) => `Limitation: ${item}`),
57
+ '',
58
+ ].join('\n');
59
+ }