@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -8,15 +8,25 @@ import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedW
8
8
  import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
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
+ import { DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
12
+ import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, verifyInvestigationCapsule, } from "../frontend/src/lib/analysis/investigation-capsule.js";
11
13
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
12
14
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
13
15
  export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
14
16
  export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
17
+ export class UnsupportedOfflineArtifactError extends TypeError {
18
+ code = 'unsupported_artifact';
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'UnsupportedOfflineArtifactError';
22
+ }
23
+ }
15
24
  const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
16
25
  [ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([ACQUISITION_DECISION_PACKET_VERSION]),
17
26
  [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
18
27
  [BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
19
28
  [BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
29
+ [DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_MANIFEST_VERSION]),
20
30
  });
21
31
  function record(value) {
22
32
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -25,7 +35,7 @@ function record(value) {
25
35
  }
26
36
  function parseJson(raw) {
27
37
  if (typeof raw !== 'string')
28
- throw new TypeError('Artifact input must be UTF-8 JSON text.');
38
+ throw new TypeError('Artefact input must be UTF-8 JSON text.');
29
39
  const bytes = Buffer.byteLength(raw, 'utf8');
30
40
  if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
31
41
  throw new TypeError(`Artifact input must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
@@ -35,18 +45,22 @@ function parseJson(raw) {
35
45
  parsed = JSON.parse(raw);
36
46
  }
37
47
  catch {
38
- throw new TypeError('Artifact input is not valid JSON.');
48
+ throw new TypeError('Artefact input is not valid JSON.');
39
49
  }
40
50
  const value = record(parsed);
41
51
  if (!value)
42
- throw new TypeError('Artifact input must contain one JSON object.');
52
+ throw new TypeError('Artefact input must contain one JSON object.');
43
53
  return value;
44
54
  }
45
55
  function artifactVersion(value) {
46
- if (!Number.isSafeInteger(value.version) || Number(value.version) < 1 || Number(value.version) > 1000) {
47
- throw new TypeError('Artifact version is missing or invalid.');
56
+ const declared = value.version ?? value.schemaVersion;
57
+ if (value.version !== undefined && value.schemaVersion !== undefined && value.version !== value.schemaVersion) {
58
+ throw new TypeError('Artefact version declarations do not agree.');
59
+ }
60
+ if (!Number.isSafeInteger(declared) || Number(declared) < 1 || Number(declared) > 1000) {
61
+ throw new TypeError('Artefact version is missing or invalid.');
48
62
  }
49
- return Number(value.version);
63
+ return Number(declared);
50
64
  }
51
65
  function inputBytes(raw) {
52
66
  return Buffer.byteLength(raw, 'utf8');
@@ -81,19 +95,21 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
81
95
  async function verifySignedArtifact(raw, value, schema, version) {
82
96
  const supported = SIGNED_ARTIFACT_VERSIONS[schema];
83
97
  if (!supported?.has(version)) {
84
- throw new TypeError('This signed review-artifact schema or version is not supported.');
98
+ throw new UnsupportedOfflineArtifactError('This signed review-artifact schema or version is not supported.');
85
99
  }
86
100
  const integrity = record(value.integrity);
87
101
  if (!integrity
88
102
  || integrity.algorithm !== 'SHA-256'
89
103
  || typeof integrity.digestSha256 !== 'string'
90
104
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
91
- throw new TypeError('The signed review artifact has a missing or malformed integrity envelope.');
105
+ throw new TypeError('The signed review artefact has a missing or malformed integrity envelope.');
92
106
  }
93
107
  const { integrity: _integrity, ...unsigned } = value;
94
108
  if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
95
- throw new TypeError('The signed review artifact failed its SHA-256 integrity check.');
109
+ throw new TypeError('The signed review artefact failed its SHA-256 integrity check.');
96
110
  }
111
+ if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA)
112
+ verifyDomainControlManifest(value);
97
113
  return Object.freeze({
98
114
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
99
115
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -112,7 +128,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
112
128
  ciphertextBytes: null,
113
129
  }),
114
130
  limitations: Object.freeze([
115
- 'Digest verification detects changes to this exported artifact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
131
+ 'Digest verification detects changes to this exported artefact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
116
132
  ]),
117
133
  });
118
134
  }
@@ -159,7 +175,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
159
175
  }
160
176
  if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
161
177
  if (version !== CASE_RESPONSE_PACKET_VERSION) {
162
- throw new TypeError('This case-response packet version is not supported.');
178
+ throw new UnsupportedOfflineArtifactError('This case-response packet version is not supported.');
163
179
  }
164
180
  const integrity = record(value.integrity);
165
181
  if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
@@ -187,11 +203,41 @@ export async function verifyOfflineArtifact(raw, options = {}) {
187
203
  ]),
188
204
  });
189
205
  }
206
+ if (schema === INVESTIGATION_CAPSULE_SCHEMA) {
207
+ if (version !== INVESTIGATION_CAPSULE_VERSION) {
208
+ throw new UnsupportedOfflineArtifactError('This investigation-capsule version is not supported.');
209
+ }
210
+ const verification = await verifyInvestigationCapsule(value);
211
+ if (!verification.valid)
212
+ throw new TypeError('The investigation capsule failed its embedded projection integrity checks.');
213
+ return Object.freeze({
214
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
215
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
216
+ artifact: Object.freeze({ kind: 'investigation_capsule', schema, version }),
217
+ state: 'verified',
218
+ valid: true,
219
+ checks: Object.freeze({
220
+ structure: 'verified',
221
+ contentIntegrity: 'verified',
222
+ authenticatedEncryption: 'not_applicable',
223
+ }),
224
+ summary: Object.freeze({
225
+ inputBytes: inputBytes(raw),
226
+ sectionCount: null,
227
+ recordCount: null,
228
+ ciphertextBytes: null,
229
+ }),
230
+ limitations: Object.freeze([
231
+ '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.',
232
+ 'Digest verification detects changed content but does not authenticate the analyst, signer, collection source, or truth of retained observations and assertions.',
233
+ ]),
234
+ });
235
+ }
190
236
  if (schema === SAVED_LOOKUP_SCHEMA) {
191
237
  if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
192
- throw new TypeError('This saved Lookup document version is not supported.');
238
+ throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
193
239
  }
194
- const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
240
+ const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artefact' });
195
241
  return Object.freeze({
196
242
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
197
243
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -218,7 +264,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
218
264
  }
219
265
  export function formatOfflineArtifactVerification(report) {
220
266
  const lines = [
221
- 'WHOISleuth offline artifact verification',
267
+ 'WHOISleuth offline artefact verification',
222
268
  `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
223
269
  `State: ${report.state}`,
224
270
  `Structure: ${report.checks.structure}`,
package/cli/bulk.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { abortable } from "../lib/abort.mjs";
3
3
  import { CliUsageError } from "./arguments.mjs";
4
+ import { MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, } from "../lib/bulk-limits.mjs";
4
5
  const MAX_BULK_INPUT_BYTES = 1024 * 1024;
5
- const MAX_FAST_BULK_QUERIES = 500;
6
- const MAX_DEEP_BULK_QUERIES = 50;
7
6
  async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES) {
8
7
  if (!stream || stream.isTTY)
9
8
  return '';
package/cli/compare.mjs CHANGED
@@ -150,7 +150,7 @@ function projectCliRegistrarPublicationInput(document) {
150
150
  }
151
151
  const parsed = objectOrNull(registrar?.parsed);
152
152
  if (status === 'success' && !parsed) {
153
- throw new CliUsageError('Successful registrar RDAP input is missing normalized parsed data.');
153
+ throw new CliUsageError('Successful registrar RDAP input is missing normalised parsed data.');
154
154
  }
155
155
  const projected = projectRdapSource(parsed, 'rdap.registrarRdap.parsed');
156
156
  delete projected.handle;
@@ -1,7 +1,8 @@
1
1
  import { CLI_COMMANDS } from "./arguments.mjs";
2
+ import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
2
3
  const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
3
4
  const OPTIONS_BY_COMMAND = Object.freeze({
4
- lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
+ lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
6
  bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
6
7
  'ct-search': ['--json', '--quiet', '--no-color'],
7
8
  discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
@@ -10,7 +11,9 @@ const OPTIONS_BY_COMMAND = Object.freeze({
10
11
  http: ['--json', '--quiet', '--no-color'],
11
12
  tls: ['--json', '--quiet', '--no-color'],
12
13
  'registry-support': ['--json', '--quiet', '--no-color'],
14
+ 'registry-doctor': ['--json', '--quiet', '--no-color'],
13
15
  'risk-calibrate': ['--json', '--quiet', '--no-color'],
16
+ 'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
14
17
  'verify-artifact': ['--passphrase-file', '--json', '--quiet', '--no-color'],
15
18
  'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--json', '--quiet', '--no-color'],
16
19
  'sign-artifact': ['--private-key-file'],
@@ -19,7 +22,13 @@ const OPTIONS_BY_COMMAND = Object.freeze({
19
22
  compare: ['--json', '--quiet', '--no-color'],
20
23
  'page-compare': ['--json', '--quiet', '--no-color'],
21
24
  'mail-review': ['--json', '--quiet', '--no-color'],
25
+ 'review-evidence': ['--mmdb', '--json', '--quiet', '--no-color'],
26
+ 'domain-control': ['--json', '--quiet', '--no-color'],
27
+ assurance: ['--json', '--quiet', '--no-color'],
28
+ 'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
29
+ 'workflow-plan': ['--json', '--quiet', '--no-color'],
22
30
  diff: ['--json', '--quiet', '--no-color'],
31
+ reconcile: ['--json', '--quiet', '--no-color'],
23
32
  timeline: ['--json', '--quiet', '--no-color'],
24
33
  export: ['--markdown', '--html', '--compact'],
25
34
  completion: [],
@@ -37,16 +46,24 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
37
46
  http: 'Inspect one homepage request',
38
47
  tls: 'Inspect one TLS connection',
39
48
  'registry-support': 'Explain local registry coverage',
49
+ 'registry-doctor': 'Diagnose saved registry collection',
40
50
  'risk-calibrate': 'Replay reviewed Risk labels offline',
51
+ 'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
41
52
  'verify-artifact': 'Validate saved evidence offline',
42
53
  'inspect-archive': 'Inspect an archive locally',
43
- 'sign-artifact': 'Sign a reviewed artifact locally',
54
+ 'sign-artifact': 'Sign a reviewed artefact locally',
44
55
  'verify-signature': 'Verify a signed evidence package',
45
56
  'source-report': 'Build a target-free source report',
46
57
  compare: 'Compare registry publications in one lookup',
47
58
  'page-compare': 'Compare saved static page evidence',
48
59
  'mail-review': 'Review saved passive mail evidence',
60
+ 'review-evidence': 'Review supplied evidence offline',
61
+ 'domain-control': 'Build or review a domain control manifest',
62
+ assurance: 'Review domain change, recovery, or retirement plans',
63
+ 'sharing-review': 'Lint an artefact before deliberate sharing',
64
+ 'workflow-plan': 'Plan a fixed investigation recipe',
49
65
  diff: 'Compare two saved domain lookups',
66
+ reconcile: 'Reconcile independently labelled observations',
50
67
  timeline: 'Build same-domain history from saved lookups',
51
68
  export: 'Convert a lookup to an evidence report',
52
69
  completion: 'Print shell completion',
@@ -58,6 +75,8 @@ const VALUE_OPTIONS = Object.freeze({
58
75
  '--preset': ['common', 'impersonation', 'all'],
59
76
  '--keyboard': ['qwerty', 'azerty', 'qwertz', 'all'],
60
77
  '--mail-profile': ['standard', 'defensive-no-mail', 'parked'],
78
+ '--marking': ['clear', 'green', 'amber', 'amber-strict', 'red'],
79
+ '--recipient-scope': ['public', 'community', 'organization', 'named-recipients'],
61
80
  '--concurrency': ['1', '2', '3', '4', '5', '6', '7', '8'],
62
81
  });
63
82
  const FILE_OPTIONS = Object.freeze([
@@ -74,10 +93,13 @@ const FILE_OPTIONS = Object.freeze([
74
93
  const TEXT_OPTIONS = Object.freeze([
75
94
  '--families',
76
95
  '--resolver',
96
+ '--purpose',
97
+ '--observer',
77
98
  '--retired-selectors',
78
99
  '--search',
79
100
  '--selectors',
80
101
  '--tlds',
102
+ '--vantage',
81
103
  ]);
82
104
  function commandOptions(command) {
83
105
  return [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
@@ -99,6 +121,10 @@ _whoisleuth_completion() {
99
121
  COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
100
122
  return
101
123
  fi
124
+ if [[ "\${command}" == "workflow-plan" && \${COMP_CWORD} -eq 2 ]]; then
125
+ COMPREPLY=( $(compgen -W "${INVESTIGATION_PLAN_RECIPES.join(' ')}" -- "\${current}") )
126
+ return
127
+ fi
102
128
  case "\${previous}" in
103
129
  ${valueCases}
104
130
  ${FILE_OPTIONS.join('|')}) COMPREPLY=( $(compgen -f -- "\${current}") ); return ;;
@@ -132,6 +158,10 @@ _whoisleuth() {
132
158
  compadd -- bash zsh fish powershell
133
159
  return
134
160
  fi
161
+ if [[ "\${command}" == "workflow-plan" && CURRENT -eq 3 ]]; then
162
+ compadd -- ${INVESTIGATION_PLAN_RECIPES.join(' ')}
163
+ return
164
+ fi
135
165
  case "\${previous}" in
136
166
  ${valueCases}
137
167
  ${FILE_OPTIONS.join('|')}) _files; return ;;
@@ -165,6 +195,7 @@ complete -c whoisleuth -n '__fish_use_subcommand' -l help
165
195
  complete -c whoisleuth -n '__fish_use_subcommand' -l version
166
196
  ${commandLines.join('\n')}
167
197
  complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
198
+ complete -c whoisleuth -n '__fish_seen_subcommand_from workflow-plan' -a '${INVESTIGATION_PLAN_RECIPES.join(' ')}'
168
199
  ${optionLines.join('\n')}
169
200
  ${valueLines.join('\n')}
170
201
  `;
@@ -184,6 +215,7 @@ ${Object.entries(commandOptions).map(([command, options]) => ` '${command}' =
184
215
  $values = @{
185
216
  ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
186
217
  'completion' = @('bash', 'zsh', 'fish', 'powershell')
218
+ 'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
187
219
  }
188
220
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
189
221
  $command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
@@ -192,6 +224,8 @@ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @($
192
224
  @($commands) + @('--help', '--version')
193
225
  } elseif ($command -eq 'completion' -and $elements.Count -le 3) {
194
226
  $values['completion']
227
+ } elseif ($command -eq 'workflow-plan' -and $elements.Count -le 3) {
228
+ $values['workflow-plan']
195
229
  } elseif ($values.ContainsKey($previous)) {
196
230
  $values[$previous]
197
231
  } elseif ($options.ContainsKey($command)) {
package/cli/doctor.mjs CHANGED
@@ -1,6 +1,4 @@
1
- import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
2
1
  import { withTimeout } from "../lib/abort.mjs";
3
- import { whoisQuery } from "../lib/whois-transport.mjs";
4
2
  const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
5
3
  const DOCTOR_VERSION = 1;
6
4
  const MINIMUM_NODE_MAJOR = 24;
@@ -62,9 +60,17 @@ async function buildDoctorReport(options) {
62
60
  }));
63
61
  }
64
62
  else {
65
- const resolve = options.resolveAddresses || resolvePublicAddresses;
66
- const fetchHttps = options.fetchHttps || safeFetch;
67
- const query = options.queryWhois || whoisQuery;
63
+ const safeFetchModule = options.resolveAddresses && options.fetchHttps
64
+ ? null
65
+ : await import("../lib/safe-fetch.mjs");
66
+ const whoisModule = options.queryWhois
67
+ ? null
68
+ : await import("../lib/whois-transport.mjs");
69
+ const resolve = options.resolveAddresses || safeFetchModule?.resolvePublicAddresses;
70
+ const fetchHttps = options.fetchHttps || safeFetchModule?.safeFetch;
71
+ const query = options.queryWhois || whoisModule?.whoisQuery;
72
+ if (!resolve || !fetchHttps || !query)
73
+ throw new TypeError('Network diagnostic dependencies are unavailable.');
68
74
  const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
69
75
  && Number(options.networkTimeoutMs) >= 1
70
76
  && Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
@@ -92,7 +92,7 @@ export function parseSignArtifactArguments(argv) {
92
92
  else if (source === null)
93
93
  source = argument;
94
94
  else
95
- throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one artifact on stdin.');
95
+ throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one artefact on stdin.');
96
96
  }
97
97
  if (!privateKeySource)
98
98
  throw new CliUsageError('sign-artifact requires --private-key-file.');
@@ -80,9 +80,9 @@ export async function runEvidenceCommand(args, dependencies) {
80
80
  return EXIT_CODES.SUCCESS;
81
81
  }
82
82
  if (args.action === 'sign-artifact') {
83
- const input = await readArtifact(args.source, 'Evidence artifact input', dependencies);
83
+ const input = await readArtifact(args.source, 'Evidence artefact input', dependencies);
84
84
  if (!input.trim()) {
85
- throw new CliUsageError('sign-artifact requires one reviewed artifact file or an artifact on stdin.');
85
+ throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
86
86
  }
87
87
  const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
88
88
  const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
@@ -100,10 +100,10 @@ function payloadBytes(payload) {
100
100
  return Buffer.from(canonicalArtifactJson(payload), 'utf8');
101
101
  }
102
102
  async function signableArtifact(raw) {
103
- const artifact = parseObject(raw, 'Evidence artifact');
103
+ const artifact = parseObject(raw, 'Evidence artefact');
104
104
  const verification = await verifyOfflineArtifact(raw);
105
- if (!['case_response_packet', 'signed_review_artifact'].includes(verification.artifact.kind)) {
106
- throw new TypeError('Only reviewed response packets and supported review manifests can be signed.');
105
+ if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
106
+ throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
107
107
  }
108
108
  return { artifact, verification };
109
109
  }
@@ -193,7 +193,7 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
193
193
  trustedPublicKeyPem
194
194
  ? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
195
195
  : 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
196
- 'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialized file.',
196
+ 'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
197
197
  'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
198
198
  'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
199
199
  ]),
@@ -1,5 +1,8 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { projectCliLookupComparisonInput } from "./compare.mjs";
2
3
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
+ const require = createRequire(import.meta.url);
5
+ const { version: APPLICATION_VERSION } = require('../package.json');
3
6
  function objectOrNull(value) {
4
7
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
8
  }
@@ -14,7 +17,11 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
14
17
  throw new TypeError('Lookup evidence export dependency is required.');
15
18
  }
16
19
  const buildLookupEvidence = dependency.buildLookupEvidence;
17
- const result = objectOrNull(buildLookupEvidence(source, { generatedAt, idnAnalysis: null }));
20
+ const result = objectOrNull(buildLookupEvidence(source, {
21
+ generatedAt,
22
+ idnAnalysis: null,
23
+ applicationVersion: APPLICATION_VERSION,
24
+ }));
18
25
  if (!result
19
26
  || result.schema !== dependency.LOOKUP_EVIDENCE_SCHEMA
20
27
  || result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
@@ -25,4 +32,4 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
25
32
  function formatCliEvidenceExport(document, compact = false) {
26
33
  return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
27
34
  }
28
- export { buildCliEvidenceExport, formatCliEvidenceExport };
35
+ export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
@@ -12,7 +12,7 @@ function renderGroups(groups) {
12
12
  }
13
13
  function renderComparison(comparison) {
14
14
  if (!comparison.fields.length)
15
- return '<p class="empty">No comparable normalized fields were published.</p>';
15
+ return '<p class="empty">No comparable normalised fields were published.</p>';
16
16
  const rows = comparison.fields.map((field) => `<tr><th scope="row">${escapeHtml(field.label)}</th><td><span class="status">${escapeHtml(field.status)}</span></td><td>${escapeHtml(field.rdap)}</td><td>${escapeHtml(field.whois)}</td></tr>`).join('');
17
17
  const omitted = comparison.omitted
18
18
  ? `<p class="omission">${escapeHtml(comparison.omitted)} additional comparison fields omitted.</p>`
@@ -17,7 +17,13 @@ const CLI_POSTURE_SCHEMA_VERSION = 1;
17
17
  const CLI_HTTP_SCHEMA_VERSION = 1;
18
18
  const CLI_TLS_SCHEMA_VERSION = 1;
19
19
  const CLI_COMPARE_SCHEMA_VERSION = 3;
20
- function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast') {
20
+ function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast', collectionContext = {}) {
21
+ const observerLabel = typeof collectionContext.observerLabel === 'string'
22
+ ? collectionContext.observerLabel
23
+ : null;
24
+ const vantageLabel = typeof collectionContext.vantageLabel === 'string'
25
+ ? collectionContext.vantageLabel
26
+ : null;
21
27
  return {
22
28
  schema: CLI_LOOKUP_SCHEMA,
23
29
  version: CLI_LOOKUP_SCHEMA_VERSION,
@@ -28,6 +34,12 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
28
34
  inputHostname: classified.inputHostname,
29
35
  registrableDomain: classified.registrableDomain,
30
36
  isSubdomain: classified.isSubdomain,
37
+ ...((observerLabel || vantageLabel) ? {
38
+ collectionContext: {
39
+ ...(observerLabel ? { observerLabel } : {}),
40
+ ...(vantageLabel ? { vantageLabel } : {}),
41
+ },
42
+ } : {}),
31
43
  ...result,
32
44
  };
33
45
  }
@@ -271,7 +271,6 @@ function formatTerminalRegistrySupport(document) {
271
271
  `WHOIS scope ${supportLabel(whois.queryScope)}`,
272
272
  `WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
273
273
  `WHOIS parser ${supportLabel(whois.parserProfile)}`,
274
- `Fallback ${profile.fallbackProfile ? supportLabel(profile.fallbackProfile) : 'None'}`,
275
274
  `Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
276
275
  ];
277
276
  for (const file of files)
@@ -302,6 +301,11 @@ function formatTerminalBulk(items, metadata) {
302
301
  }
303
302
  function formatTerminalCtSearch(document) {
304
303
  const matches = Array.isArray(document.matches) ? document.matches : [];
304
+ const certificateGroups = Array.isArray(document.certificateGroups) ? document.certificateGroups : [];
305
+ const crossDomainGroups = certificateGroups.filter((value) => {
306
+ const group = terminalRecord(value);
307
+ return Array.isArray(group.domains) && group.domains.length > 1;
308
+ });
305
309
  const visible = matches.slice(0, MAX_CT_TERMINAL_MATCHES);
306
310
  const observation = terminalRecord(document.observation);
307
311
  const lines = [
@@ -310,7 +314,9 @@ function formatTerminalCtSearch(document) {
310
314
  `Certificates ${safeTerminalValue(document.certCount, '0')}`,
311
315
  `Observed hosts ${safeTerminalValue(Array.isArray(document.domains) ? document.domains.length : 0, '0')}`,
312
316
  `Matches ${safeTerminalValue(matches.length, '0')}`,
317
+ `Issuance groups ${safeTerminalValue(certificateGroups.length, '0')} (${safeTerminalValue(crossDomainGroups.length, '0')} cross-domain)`,
313
318
  `Truncated ${document.truncated ? 'Yes' : 'No'}`,
319
+ `Group cap ${document.certificateGroupsTruncated ? 'Reached' : 'Not reached'}`,
314
320
  '',
315
321
  ];
316
322
  if (!visible.length) {
@@ -587,7 +593,7 @@ function formatTerminalCompare(document) {
587
593
  }
588
594
  lines.push('');
589
595
  if (!fields.length) {
590
- lines.push('Neither source published a comparable normalized field.');
596
+ lines.push('Neither source published a comparable normalised field.');
591
597
  }
592
598
  else {
593
599
  for (const value of fields) {
@@ -607,7 +613,7 @@ function formatTerminalCompare(document) {
607
613
  const publicationDifferences = publicationFields.length - (Number(publicationCounts.equivalent) || 0);
608
614
  lines.push('', 'Registry / registrar RDAP publication', `Registry RDAP ${comparisonStatusLabel(registryHealth.status)}`, `Registrar RDAP ${comparisonStatusLabel(registrarHealth.status)}`, `Compared ${safeTerminalValue(publicationFields.length, '0')} field${publicationFields.length === 1 ? '' : 's'}`, `Equivalent ${safeTerminalValue(publicationCounts.equivalent, '0')}`, `Differences ${safeTerminalValue(publicationDifferences, '0')}`, '');
609
615
  if (!publicationFields.length) {
610
- lines.push('Neither RDAP publication exposed a comparable normalized field.');
616
+ lines.push('Neither RDAP publication exposed a comparable normalised field.');
611
617
  }
612
618
  else {
613
619
  for (const value of publicationFields) {
@@ -629,6 +635,7 @@ function formatTerminalRiskCalibration(document) {
629
635
  const bands = terminalRecord(summary.scoreBands);
630
636
  const thresholds = Array.isArray(document.thresholds) ? document.thresholds : [];
631
637
  const records = Array.isArray(document.records) ? document.records : [];
638
+ const comparison = terminalRecord(document.modelComparison);
632
639
  const visible = records.slice(0, MAX_RISK_CALIBRATION_TERMINAL_RECORDS);
633
640
  const lines = [
634
641
  `Risk model v${safeTerminalValue(document.riskModelVersion)}`,
@@ -642,7 +649,10 @@ function formatTerminalRiskCalibration(document) {
642
649
  ];
643
650
  for (const value of thresholds) {
644
651
  const threshold = terminalRecord(value);
645
- lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)}`);
652
+ lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)} F1 ${safeTerminalValue(threshold.f1)}`);
653
+ }
654
+ if (comparison.available === true) {
655
+ lines.push('', `Model comparison v${safeTerminalValue(comparison.previousModelVersion)} → v${safeTerminalValue(comparison.currentModelVersion)} · ${safeTerminalValue(comparison.scoresChanged, '0')} score changes · ${safeTerminalValue(comparison.thresholdClassificationsChanged, '0')} review-band changes`);
646
656
  }
647
657
  lines.push('', 'Records');
648
658
  for (const value of visible) {
@@ -0,0 +1,112 @@
1
+ import { domainToASCII } from 'node:url';
2
+ export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
3
+ export const CLI_INVESTIGATION_PLAN_VERSION = 1;
4
+ export const INVESTIGATION_PLAN_RECIPES = [
5
+ 'domain-triage',
6
+ 'lookalike-review',
7
+ 'owned-domain-review',
8
+ 'historical-comparison',
9
+ ];
10
+ const RECIPES = Object.freeze({
11
+ 'domain-triage': Object.freeze({
12
+ label: 'New domain triage',
13
+ requiresDomain: true,
14
+ objective: 'Collect and preserve a source-aware registration, DNS, HTTP, TLS, page, and network-context review.',
15
+ steps: (domain) => Object.freeze([
16
+ step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
17
+ step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
18
+ step('verify', 'Verify the exported artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
19
+ ]),
20
+ }),
21
+ 'lookalike-review': Object.freeze({
22
+ label: 'Lookalike candidate review',
23
+ requiresDomain: false,
24
+ objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
25
+ steps: (subject) => Object.freeze([
26
+ step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
27
+ step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
28
+ step('inspect', 'Deep-review one selected candidate', 'lookup', ['<selected-domain>', '--deep', '--json'], 'network', 'analyst_selection', 'whoisleuth.cli.lookup', 'Select a candidate deliberately; generation does not prove registration, control, intent, or maliciousness.'),
29
+ ]),
30
+ }),
31
+ 'owned-domain-review': Object.freeze({
32
+ label: 'Owned domain posture review',
33
+ requiresDomain: true,
34
+ objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
35
+ steps: (domain) => Object.freeze([
36
+ step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
37
+ step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
38
+ step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only complete supplied observations may produce drift.'),
39
+ ]),
40
+ }),
41
+ 'historical-comparison': Object.freeze({
42
+ label: 'Historical observation comparison',
43
+ requiresDomain: true,
44
+ objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
45
+ steps: (domain) => Object.freeze([
46
+ step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
47
+ step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
48
+ step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
49
+ ]),
50
+ }),
51
+ });
52
+ function step(id, label, command, args, mode, approval, produces, completion) {
53
+ return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
54
+ }
55
+ function boundedSubject(value) {
56
+ if (typeof value !== 'string' || !value.trim() || value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
57
+ throw new TypeError('Investigation-plan subject must be bounded text without control characters.');
58
+ }
59
+ return value.replace(/\s+/gu, ' ').trim();
60
+ }
61
+ function normalizedDomain(value) {
62
+ const candidate = domainToASCII(value.toLowerCase().replace(/\.$/u, ''));
63
+ if (!candidate || candidate.length > 253 || !candidate.includes('.'))
64
+ return null;
65
+ return candidate.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
66
+ ? candidate
67
+ : null;
68
+ }
69
+ export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue = new Date().toISOString()) {
70
+ const recipe = RECIPES[recipeId];
71
+ if (!recipe)
72
+ throw new TypeError('Investigation-plan recipe is unsupported.');
73
+ const suppliedSubject = boundedSubject(subjectValue);
74
+ const subject = recipe.requiresDomain ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
75
+ if (!subject)
76
+ throw new TypeError('This investigation-plan recipe requires one valid domain.');
77
+ const parsedTime = Date.parse(generatedAtValue);
78
+ if (!Number.isFinite(parsedTime))
79
+ throw new TypeError('Investigation-plan generation time is invalid.');
80
+ const steps = recipe.steps(subject);
81
+ return Object.freeze({
82
+ schema: CLI_INVESTIGATION_PLAN_SCHEMA,
83
+ version: CLI_INVESTIGATION_PLAN_VERSION,
84
+ generatedAt: new Date(parsedTime).toISOString(),
85
+ recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
86
+ subject,
87
+ execution: 'plan_only',
88
+ steps,
89
+ limitations: Object.freeze([
90
+ 'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
91
+ 'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
92
+ 'Analyst-selection steps require reviewed local artefacts; placeholders are never interpreted as file paths by this planner.',
93
+ ]),
94
+ });
95
+ }
96
+ export function formatInvestigationPlan(plan) {
97
+ return [
98
+ `Investigation plan: ${plan.recipe.label}`,
99
+ `Subject ${plan.subject}`,
100
+ `Mode ${plan.execution}`,
101
+ '',
102
+ ...plan.steps.flatMap((item, index) => [
103
+ `${index + 1}. ${item.label}`,
104
+ ` ${item.command} ${item.arguments.join(' ')}`,
105
+ ` ${item.mode}; approval: ${item.approval.replaceAll('_', ' ')}`,
106
+ ` ${item.completion}`,
107
+ ]),
108
+ '',
109
+ ...plan.limitations.map((item) => `Limitation: ${item}`),
110
+ '',
111
+ ].join('\n');
112
+ }