@slicedearth/whoisleuth-cli 2.0.1 → 2.1.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 (95) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +4 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-response-model.mjs +13 -9
  83. package/packages/cases/case-response-packet.mjs +297 -112
  84. package/packages/comparison/page-similarity.mjs +23 -0
  85. package/packages/contracts/case-portability.mjs +225 -61
  86. package/packages/contracts/cli-lookup.mjs +4 -3
  87. package/packages/contracts/domain-control-manifest.mjs +1 -1
  88. package/packages/contracts/investigation-portability.mjs +1 -1
  89. package/packages/contracts/risk-calibration.mjs +2 -2
  90. package/packages/contracts/workspace-portability.mjs +34 -6
  91. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  92. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  93. package/packages/workspace/website-snapshot-model.mjs +79 -6
  94. package/third-party-notices.txt +1 -1
  95. package/cli/artifact-structure.mjs +0 -2507
@@ -5,6 +5,7 @@ import { isSafeJsonObjectKey, scanBoundedJson, } from "../lib/bounded-json.mjs";
5
5
  import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
6
6
  import { CliUsageError } from "./arguments.mjs";
7
7
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../lib/homepage-metadata-contract.mjs";
8
+ import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../lib/registrar-standing-contract.mjs";
8
9
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
9
10
  import { CLI_LOOKUP_SCHEMA as SAVED_LOOKUP_SCHEMA, CLI_LOOKUP_VERSION as SAVED_LOOKUP_SCHEMA_VERSION, PUBLIC_CLI_LOOKUP_VERSION as PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION, MAX_CLI_LOOKUP_BYTES as MAX_SAVED_LOOKUP_INPUT_BYTES, MAX_CLI_LOOKUP_JSON_CONTAINER_ITEMS as MAX_BOUNDED_JSON_CONTAINER_ITEMS, MAX_CLI_LOOKUP_JSON_DEPTH as MAX_BOUNDED_JSON_DEPTH, MAX_CLI_LOOKUP_JSON_KEYS as MAX_BOUNDED_JSON_KEYS, MAX_CLI_LOOKUP_JSON_VALUES as MAX_BOUNDED_JSON_VALUES, MAX_CLI_LOOKUP_STRING_LENGTH as MAX_SAVED_LOOKUP_STRING_LENGTH, SUPPORTED_CLI_LOOKUP_VERSIONS as SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, } from "../packages/contracts/cli-lookup.mjs";
10
11
  const RDAP_STATUSES = new Set([
@@ -315,9 +316,10 @@ function assertLookupEnvelope(document, label) {
315
316
  const httpResponse = objectOrNull(http?.response);
316
317
  const publicationMetadata = pageIdentity?.publicationMetadata;
317
318
  const deliveryMetadata = httpResponse?.deliveryMetadata;
319
+ const registrarStanding = document.registrarStanding;
318
320
  if (document.version === PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION
319
- && (publicationMetadata !== undefined || deliveryMetadata !== undefined)) {
320
- throw new TypeError(`${label} version ${PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION} cannot contain version ${SAVED_LOOKUP_SCHEMA_VERSION} homepage metadata.`);
321
+ && (publicationMetadata !== undefined || deliveryMetadata !== undefined || registrarStanding !== undefined)) {
322
+ throw new TypeError(`${label} version ${PUBLIC_SAVED_LOOKUP_SCHEMA_VERSION} cannot contain version ${SAVED_LOOKUP_SCHEMA_VERSION} evidence fields.`);
321
323
  }
322
324
  if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
323
325
  && (publicationMetadata !== undefined && (!['success', 'partial'].includes(String(pageIdentity?.status))
@@ -326,6 +328,16 @@ function assertLookupEnvelope(document, label) {
326
328
  || !validHttpDeliveryMetadata(deliveryMetadata)))) {
327
329
  throw new TypeError(`${label} contains invalid homepage metadata.`);
328
330
  }
331
+ if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
332
+ && registrarStanding !== undefined
333
+ && !validRegistrarStanding(registrarStanding)) {
334
+ throw new TypeError(`${label} contains invalid registrar standing.`);
335
+ }
336
+ if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
337
+ && registrarStanding !== undefined
338
+ && !registrarStandingObservedBy(registrarStanding, generatedAt)) {
339
+ throw new TypeError(`${label} registrar standing was observed after the saved Lookup time.`);
340
+ }
329
341
  const rdapParsed = objectOrNull(rdap?.parsed);
330
342
  const whoisParsed = objectOrNull(whois?.parsed);
331
343
  if (rdapStatus === 'success' && !rdapParsed) {
@@ -334,6 +346,11 @@ function assertLookupEnvelope(document, label) {
334
346
  if ((whoisStatus === 'complete' || whoisStatus === 'partial') && !whoisParsed) {
335
347
  throw new TypeError('Successful WHOIS input is missing normalised parsed data.');
336
348
  }
349
+ if (document.version === SAVED_LOOKUP_SCHEMA_VERSION
350
+ && validRegistrarStanding(registrarStanding)
351
+ && registrarStanding.ianaId !== resolveRegistrarIanaId(rdapParsed, whoisParsed)) {
352
+ throw new TypeError(`${label} registrar standing does not match its retained registration sources.`);
353
+ }
337
354
  return generatedAt;
338
355
  }
339
356
  function normalizeCliLookupDocument(value, options = {}) {
@@ -0,0 +1,229 @@
1
+ import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
2
+ import { buildRiskCalibrationSummaryReport } from "../lib/risk-calibration-summary.mjs";
3
+ import { explainRiskScore, explainRiskScoreV7, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
4
+ import { buildCliCommandCatalogue, formatCliCommandCatalogue, selectCliCommands } from "./command-catalogue.mjs";
5
+ import { CLI_COMMAND_REGISTRY, CLI_COMMANDS, COMMAND_COLLECTION, COMMAND_DETAILS, COMMAND_USAGE, } from "./command-reference.mjs";
6
+ import { buildShellCompletion } from "./completion.mjs";
7
+ import { buildDoctorReport, formatDoctorReport } from "./doctor.mjs";
8
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
9
+ import EXIT_CODES from "./exit-codes.mjs";
10
+ import { formatJsonDocument } from "./formatters/json.mjs";
11
+ import { formatTerminalRegistrySupport, formatTerminalRiskCalibration, } from "./formatters/terminal.mjs";
12
+ import { buildCliManual } from "./manual.mjs";
13
+ import { MAX_LOOKALIKE_CALIBRATION_BYTES, buildLookalikeCalibration, formatLookalikeCalibration, } from "./lookalike-calibration.mjs";
14
+ import { MAX_REGISTRY_COHORT_INPUT_BYTES, buildRegistryCohortReport, formatRegistryCohortReport, } from "./registry-cohort.mjs";
15
+ import { buildRegistryDoctorReport, formatRegistryDoctorReport } from "./registry-doctor.mjs";
16
+ import { buildRegistryFixtureScaffold } from "./registry-fixture-scaffold.mjs";
17
+ import { buildRegistrySupportDocument } from "./registry-support.mjs";
18
+ import { MAX_RISK_CALIBRATION_INPUT_BYTES, buildRiskCalibrationReport, parseRiskCalibrationDataset, serializeRiskCalibrationReport, } from "./risk-calibration.mjs";
19
+ import { MAX_SAVED_LOOKUP_INPUT_BYTES } from "./saved-lookup.mjs";
20
+ import { SUPPORT_INLINE_COMMANDS } from "./inline-command-families.mjs";
21
+ import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
22
+ async function runCompletionCommand(args, dependencies, context) {
23
+ context.writeStdout(buildShellCompletion(args.shell));
24
+ return EXIT_CODES.SUCCESS;
25
+ }
26
+ async function runCommandCatalogueCommand(args, dependencies, context) {
27
+ const selectedCommands = selectCliCommands(CLI_COMMAND_REGISTRY, {
28
+ common: args.common,
29
+ group: args.group,
30
+ mode: args.mode,
31
+ });
32
+ const catalogue = buildCliCommandCatalogue({
33
+ commands: selectedCommands,
34
+ collections: COMMAND_COLLECTION,
35
+ details: COMMAND_DETAILS,
36
+ usage: COMMAND_USAGE,
37
+ packageVersion: context.packageVersion,
38
+ });
39
+ if (!args.quiet) {
40
+ context.writeStdout(args.output === 'json'
41
+ ? formatJsonDocument(catalogue)
42
+ : context.terminal(formatCliCommandCatalogue(catalogue), args.color));
43
+ }
44
+ return EXIT_CODES.SUCCESS;
45
+ }
46
+ async function runManualCommand(args, dependencies, context) {
47
+ context.writeStdout(buildCliManual({
48
+ commands: CLI_COMMANDS,
49
+ collections: COMMAND_COLLECTION,
50
+ details: COMMAND_DETAILS,
51
+ usage: COMMAND_USAGE,
52
+ version: context.packageVersion,
53
+ }));
54
+ return EXIT_CODES.SUCCESS;
55
+ }
56
+ async function runDoctorCommand(args, dependencies, context) {
57
+ context.setFailureLabel('CLI diagnostics');
58
+ const buildReport = () => buildDoctorReport({
59
+ version: context.packageVersion,
60
+ generatedAt: context.now(),
61
+ network: args.network,
62
+ presentation: context.presentation(args.color),
63
+ ...(dependencies.resolvePublicAddresses ? { resolveAddresses: dependencies.resolvePublicAddresses } : {}),
64
+ ...(dependencies.safeFetch ? { fetchHttps: dependencies.safeFetch } : {}),
65
+ ...(dependencies.whoisQuery ? { queryWhois: dependencies.whoisQuery } : {}),
66
+ });
67
+ const report = args.network
68
+ ? await context.withProgress('Checking public DNS, HTTPS, and WHOIS connectivity', buildReport)
69
+ : await buildReport();
70
+ if (!args.quiet) {
71
+ context.writeStdout(args.output === 'json'
72
+ ? formatJsonDocument(report)
73
+ : context.terminal(formatDoctorReport(report), args.color));
74
+ }
75
+ return report.state === 'partial' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
76
+ }
77
+ async function runRegistrySupportCommand(args, dependencies, context) {
78
+ context.setFailureLabel('Registry support');
79
+ const requestedInput = args.target || await context.readSingleInput();
80
+ if (!requestedInput)
81
+ throw new CliUsageError('registry-support requires one domain or suffix as an argument or on stdin.');
82
+ const lookupCapability = dependencies.registryCapabilityFor || registryCapabilityFor;
83
+ const capability = lookupCapability(requestedInput);
84
+ if (!capability)
85
+ throw new CliUsageError('registry-support requires a valid domain or suffix.');
86
+ const catalogueVersion = dependencies.registryCapabilitiesVersion || REGISTRY_CAPABILITIES_VERSION;
87
+ const document = buildRegistrySupportDocument(requestedInput, capability, catalogueVersion, context.now());
88
+ if (!args.quiet) {
89
+ context.writeStdout(args.output === 'json'
90
+ ? formatJsonDocument(document)
91
+ : context.terminal(formatTerminalRegistrySupport(document), args.color));
92
+ }
93
+ return EXIT_CODES.SUCCESS;
94
+ }
95
+ async function runRegistryDoctorCommand(args, dependencies, context) {
96
+ context.setFailureLabel('Registry compatibility diagnostic');
97
+ let input;
98
+ try {
99
+ input = dependencies.readCompareInput
100
+ ? await dependencies.readCompareInput(args.source)
101
+ : await context.readInput(args.source, MAX_SAVED_LOOKUP_INPUT_BYTES, 'Registry doctor input');
102
+ }
103
+ catch (error) {
104
+ if (error instanceof CliUsageError)
105
+ throw error;
106
+ throw new CliUsageError(`Could not read registry doctor input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
107
+ }
108
+ if (!input.trim())
109
+ throw new CliUsageError('registry-doctor requires one saved Lookup JSON file or a document on stdin.');
110
+ const report = buildRegistryDoctorReport(input, context.now());
111
+ if (!args.quiet) {
112
+ context.writeStdout(args.output === 'json'
113
+ ? formatJsonDocument(report)
114
+ : context.terminal(formatRegistryDoctorReport(report), args.color));
115
+ }
116
+ return report.summary.investigate ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
117
+ }
118
+ async function runRegistryCohortCommand(args, dependencies, context) {
119
+ context.setFailureLabel('Registry quality cohort');
120
+ let input;
121
+ try {
122
+ input = dependencies.readCompareInput
123
+ ? await dependencies.readCompareInput(args.source)
124
+ : await context.readInput(args.source, MAX_REGISTRY_COHORT_INPUT_BYTES, 'Registry cohort input');
125
+ }
126
+ catch (error) {
127
+ if (error instanceof CliUsageError)
128
+ throw error;
129
+ throw new CliUsageError(`Could not read registry cohort input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
130
+ }
131
+ const report = buildRegistryCohortReport(input, context.now());
132
+ if (!args.quiet) {
133
+ context.writeStdout(args.output === 'json'
134
+ ? formatJsonDocument(report)
135
+ : context.terminal(formatRegistryCohortReport(report), args.color));
136
+ }
137
+ return report.cohorts.some((cohort) => cohort.state === 'review')
138
+ ? EXIT_CODES.PARTIAL_FAILURE
139
+ : EXIT_CODES.SUCCESS;
140
+ }
141
+ async function runRegistryScaffoldCommand(args, dependencies, context) {
142
+ context.setFailureLabel('Registry fixture scaffold');
143
+ try {
144
+ context.writeStdout(buildRegistryFixtureScaffold(args.profile, args.suffix, args.scenario));
145
+ }
146
+ catch (error) {
147
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Registry fixture scaffold failed'));
148
+ }
149
+ return EXIT_CODES.SUCCESS;
150
+ }
151
+ async function runRiskCalibrationCommand(args, dependencies, context) {
152
+ context.setFailureLabel('Risk calibration');
153
+ let input;
154
+ try {
155
+ input = dependencies.readRiskCalibrationInput
156
+ ? await dependencies.readRiskCalibrationInput(args.source)
157
+ : await context.readInput(args.source, MAX_RISK_CALIBRATION_INPUT_BYTES, 'Risk calibration input');
158
+ }
159
+ catch (error) {
160
+ if (error instanceof CliUsageError)
161
+ throw error;
162
+ throw new CliUsageError(`Could not read Risk calibration input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
163
+ }
164
+ if (!input.trim())
165
+ throw new CliUsageError('risk-calibrate requires one dataset JSON file or a dataset on stdin.');
166
+ const dataset = parseRiskCalibrationDataset(input);
167
+ const report = buildRiskCalibrationReport(dataset, dependencies.explainRiskScore || explainRiskScore, {
168
+ generatedAt: context.now(),
169
+ modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
170
+ reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
171
+ ...(!dependencies.explainRiskScore ? {
172
+ previousModelVersion: 7,
173
+ explainPreviousRiskScore: explainRiskScoreV7,
174
+ } : {}),
175
+ });
176
+ if (!args.quiet) {
177
+ context.writeStdout(args.output === 'summary_json'
178
+ ? serializeRiskCalibrationReport(buildRiskCalibrationSummaryReport(report))
179
+ : args.output === 'json'
180
+ ? serializeRiskCalibrationReport(report)
181
+ : context.terminal(formatTerminalRiskCalibration(report), args.color));
182
+ }
183
+ return EXIT_CODES.SUCCESS;
184
+ }
185
+ async function runLookalikeCalibrationCommand(args, dependencies, context) {
186
+ context.setFailureLabel('Lookalike review-yield calibration');
187
+ let input;
188
+ try {
189
+ input = dependencies.readRiskCalibrationInput
190
+ ? await dependencies.readRiskCalibrationInput(args.source)
191
+ : await context.readInput(args.source, MAX_LOOKALIKE_CALIBRATION_BYTES, 'Lookalike calibration input');
192
+ }
193
+ catch (error) {
194
+ if (error instanceof CliUsageError)
195
+ throw error;
196
+ throw new CliUsageError(`Could not read lookalike calibration input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
197
+ }
198
+ if (!input.trim())
199
+ throw new CliUsageError('lookalike-calibrate requires one dataset JSON file or a document on stdin.');
200
+ let report;
201
+ try {
202
+ report = buildLookalikeCalibration(input, context.now());
203
+ }
204
+ catch (error) {
205
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Lookalike calibration input is invalid'));
206
+ }
207
+ if (!args.quiet) {
208
+ context.writeStdout(args.output === 'json'
209
+ ? formatJsonDocument(report)
210
+ : context.terminal(formatLookalikeCalibration(report), args.color));
211
+ }
212
+ return EXIT_CODES.SUCCESS;
213
+ }
214
+ const SUPPORT_COMMAND_HANDLERS = Object.freeze({
215
+ 'completion': runCompletionCommand,
216
+ 'commands': runCommandCatalogueCommand,
217
+ 'manual': runManualCommand,
218
+ 'doctor': runDoctorCommand,
219
+ 'registry-support': runRegistrySupportCommand,
220
+ 'registry-doctor': runRegistryDoctorCommand,
221
+ 'registry-cohort': runRegistryCohortCommand,
222
+ 'registry-scaffold': runRegistryScaffoldCommand,
223
+ 'risk-calibrate': runRiskCalibrationCommand,
224
+ 'lookalike-calibrate': runLookalikeCalibrationCommand,
225
+ });
226
+ function runSupportCommand(args, dependencies, context) {
227
+ return runDiscriminatedCommandHandler(SUPPORT_COMMAND_HANDLERS, args, dependencies, context);
228
+ }
229
+ export { SUPPORT_COMMAND_HANDLERS, runSupportCommand };
@@ -0,0 +1,129 @@
1
+ import { formatCliJunit } from "./ci-report.mjs";
2
+ import { formatDomainControlMonitor, runDomainControlMonitor } from "./domain-control-monitor.mjs";
3
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
4
+ import EXIT_CODES from "./exit-codes.mjs";
5
+ import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
6
+ import { formatJsonDocument } from "./formatters/json.mjs";
7
+ import { buildInvestigationPlan, buildWorkflowRecipeCatalogue, formatInvestigationPlan, formatWorkflowRecipeCatalogue, } from "./investigation-plan.mjs";
8
+ import { MAX_INVESTIGATION_RUN_BYTES, formatInvestigationRun, runInvestigationRecipe, } from "./investigation-run.mjs";
9
+ import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES } from "./offline-evidence-review.mjs";
10
+ import { createBufferedOutput } from "./output-file.mjs";
11
+ import { WORKFLOW_INLINE_COMMANDS } from "./inline-command-families.mjs";
12
+ import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
13
+ async function runMonitorOnceCommand(args, dependencies, context) {
14
+ context.setFailureLabel('One-shot domain control review');
15
+ let manifestInput;
16
+ let previousInput = null;
17
+ try {
18
+ manifestInput = dependencies.readArtifactInput
19
+ ? await dependencies.readArtifactInput(args.source)
20
+ : await context.readInput(args.source, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Domain-control manifest input');
21
+ if (args.previousSource) {
22
+ previousInput = dependencies.readDiffInput
23
+ ? await dependencies.readDiffInput(args.previousSource)
24
+ : await context.readInput(args.previousSource, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Prior monitor snapshot');
25
+ }
26
+ }
27
+ catch (error) {
28
+ if (error instanceof CliUsageError)
29
+ throw error;
30
+ throw new CliUsageError(`Could not read monitor input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
31
+ }
32
+ if (!manifestInput.trim())
33
+ throw new CliUsageError('monitor-once requires one domain-control manifest file or a document on stdin.');
34
+ const executeLookup = dependencies.runUnifiedLookup || (await import("../lib/lookup.mjs")).runUnifiedLookup;
35
+ const progress = context.beginProgress('Collecting bounded domain-control evidence');
36
+ let document;
37
+ try {
38
+ document = await runDomainControlMonitor(manifestInput, previousInput, {
39
+ executeLookup,
40
+ now: context.now,
41
+ limit: args.limit,
42
+ concurrency: args.concurrency,
43
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
44
+ onSettled: (completed, total) => progress.update(`Collected ${completed} of ${total} owned domains`),
45
+ });
46
+ }
47
+ finally {
48
+ context.endProgress();
49
+ }
50
+ if (!args.quiet) {
51
+ context.writeStdout(args.output === 'json'
52
+ ? formatJsonDocument(document)
53
+ : args.output === 'junit'
54
+ ? formatCliJunit(document)
55
+ : context.terminal(formatDomainControlMonitor(document), args.color));
56
+ }
57
+ const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
58
+ if (policyFindings.length)
59
+ context.writeStderr(formatFailPolicyNotice(policyFindings));
60
+ return document.collection.failed || policyFindings.length
61
+ ? EXIT_CODES.PARTIAL_FAILURE
62
+ : EXIT_CODES.SUCCESS;
63
+ }
64
+ async function runWorkflowPlanCommand(args, dependencies, context) {
65
+ context.setFailureLabel('Investigation plan');
66
+ if ('discovery' in args) {
67
+ const catalogue = buildWorkflowRecipeCatalogue(args.discovery === 'explain' ? args.recipe : null);
68
+ if (!args.quiet) {
69
+ context.writeStdout(args.output === 'json'
70
+ ? formatJsonDocument(catalogue)
71
+ : context.terminal(formatWorkflowRecipeCatalogue(catalogue), args.color));
72
+ }
73
+ return EXIT_CODES.SUCCESS;
74
+ }
75
+ const document = buildInvestigationPlan(args.recipe, args.subject, context.now());
76
+ if (!args.quiet) {
77
+ context.writeStdout(args.output === 'json'
78
+ ? formatJsonDocument(document)
79
+ : context.terminal(formatInvestigationPlan(document), args.color));
80
+ }
81
+ return EXIT_CODES.SUCCESS;
82
+ }
83
+ async function runWorkflowRecipeCommand(args, dependencies, context) {
84
+ context.setFailureLabel('Investigation workflow');
85
+ let resumeInput = null;
86
+ if (args.resumeSource) {
87
+ try {
88
+ resumeInput = dependencies.readDiffInput
89
+ ? await dependencies.readDiffInput(args.resumeSource)
90
+ : await context.readInput(args.resumeSource, MAX_INVESTIGATION_RUN_BYTES, 'Investigation resume state');
91
+ }
92
+ catch (error) {
93
+ if (error instanceof CliUsageError)
94
+ throw error;
95
+ throw new CliUsageError(`Could not read investigation resume state: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
96
+ }
97
+ }
98
+ const document = await runInvestigationRecipe(args.recipe, args.subject, {
99
+ approveNetwork: args.approveNetwork,
100
+ selections: args.selections,
101
+ resumeInput,
102
+ generatedAt: context.now(),
103
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
104
+ execute: async (command, stepArguments) => {
105
+ const stepStdout = createBufferedOutput();
106
+ const stepStderr = createBufferedOutput();
107
+ const exitCode = await context.executeCli([command, ...stepArguments], {
108
+ stdout: stepStdout.stream,
109
+ stderr: stepStderr.stream,
110
+ });
111
+ return { exitCode, stdout: stepStdout.value() };
112
+ },
113
+ });
114
+ if (!args.quiet) {
115
+ context.writeStdout(args.output === 'json'
116
+ ? formatJsonDocument(document)
117
+ : context.terminal(formatInvestigationRun(document), args.color));
118
+ }
119
+ return document.state === 'step_failed' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
120
+ }
121
+ const WORKFLOW_COMMAND_HANDLERS = Object.freeze({
122
+ 'monitor-once': runMonitorOnceCommand,
123
+ 'workflow-plan': runWorkflowPlanCommand,
124
+ 'workflow-run': runWorkflowRecipeCommand,
125
+ });
126
+ function runWorkflowCommand(args, dependencies, context) {
127
+ return runDiscriminatedCommandHandler(WORKFLOW_COMMAND_HANDLERS, args, dependencies, context);
128
+ }
129
+ export { WORKFLOW_COMMAND_HANDLERS, runWorkflowCommand };
@@ -12,7 +12,9 @@ contains the exhaustive recipient, retention and export metadata.
12
12
 
13
13
  ## Installation
14
14
 
15
- Public releases require Node.js 24 or later:
15
+ Public releases require Node.js 24 or later. Release verification uses the
16
+ exact Node.js 24 maintainer runtime and separately exercises the installed
17
+ package on Node.js 26:
16
18
 
17
19
  ```bash
18
20
  npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
@@ -107,15 +109,16 @@ Readers accept only their declared public and current versions. Unknown,
107
109
  unreleased historical and future schemas fail before partial interpretation;
108
110
  an invalid import is not treated as an empty document.
109
111
 
110
- `inspect-archive` reads current workspace archive v6 and exact public v5. It
112
+ `inspect-archive` reads current workspace archive v7 and exact versions 5 and 6. It
111
113
  reports section metadata and digest-only search results unless `--reveal` is
112
114
  explicitly selected. It never searches notes, contacts or arbitrary raw fields.
113
115
 
114
116
  `export` reads supported saved Lookup v1 or v2 and writes current Lookup
115
- evidence schema 27. Exact public schema 26 remains readable and can contain
116
- public contact fields; current schema 27 excludes raw registration payloads,
117
- expanded contacts, credentials, complete query-bearing URLs and provider
118
- payloads.
117
+ evidence schema 28. Published v2 schema 27 and exact v1 schema 26 remain
118
+ readable. Versions 27 and 28 exclude raw registration payloads, expanded
119
+ contacts, credentials, complete query-bearing URLs and provider payloads;
120
+ schema 28 can also retain the bounded, separately attributed registrar-standing
121
+ projection shown by Lookup.
119
122
 
120
123
  `verify-artifact` checks a recognised structure and its applicable integrity
121
124
  contract. `interchange-report` describes retained and omitted fields.
@@ -133,14 +136,18 @@ evidence is accurate, current, safe to share or attributable to a person.
133
136
  control, activity, intent or maliciousness.
134
137
  - Bulk applies one declared collection contract per target. Fast accepts up to
135
138
  500 targets and Deep up to 50; each target remains a separate request.
136
- - Respond commands prepare local Cases, packets and sharing reviews. They do not
137
- submit, publish, notify or grant recipient authorisation.
139
+ - Respond commands package browser-created Cases and prepare local packets,
140
+ reports and sharing reviews. They do not create durable Cases, submit,
141
+ publish, notify or grant recipient authorisation.
138
142
  - Assurance, comparison and calibration commands describe supplied or retained
139
143
  evidence. They do not tune the running model, change infrastructure or turn an
140
144
  analyst label into observed truth.
141
145
  - `workflow-plan` lists fixed installed recipes without executing them.
142
146
  `workflow-run` executes only installed steps, requires approval for network
143
- work and pauses for analyst-selection placeholders.
147
+ work and pauses at unresolved analyst selections. Repeat
148
+ `--select <step-id>=<path-or-value>` in placeholder order to resume a selected
149
+ step; version-1 checkpoints remain readable and version 2 retains the exact
150
+ selections in the local checkpoint.
144
151
 
145
152
  Use the installed focused help for positional inputs, exact ceilings, options,
146
153
  network effects, outputs and command-specific exit behaviour.
package/docs/cli.md CHANGED
@@ -12,7 +12,9 @@ lists the network, recipient, retention and export boundary for every command.
12
12
 
13
13
  ## Installation
14
14
 
15
- Public releases require Node.js 24 or later:
15
+ Public releases require Node.js 24 or later. Release verification uses the
16
+ exact Node.js 24 maintainer runtime and separately exercises the installed
17
+ package on Node.js 26:
16
18
 
17
19
  ```bash
18
20
  npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
@@ -76,7 +78,7 @@ application:
76
78
  | Group | Common commands |
77
79
  | --- | --- |
78
80
  | Investigate | `lookup`, `bulk`, `discover`, `ct-search`, `posture`, `http`, `tls`, `compare`, `brief` |
79
- | Respond | `case-pack`, `change-packet`, `sharing-review`, `export` |
81
+ | Respond | `case-pack`, `change-packet`, `sharing-review`, `export` (local handoff from browser-created Cases) |
80
82
  | Assure | `dnssec-validate`, `mail-transport`, `domain-control`, `assurance`, `workflow-plan`, `diff`, `inspect-archive`, `verify-artifact` |
81
83
  | Utilities | `doctor`, `commands`, `completion`, `manual`, `registry-scaffold` |
82
84
 
@@ -20,6 +20,7 @@ import { buildHttpObservation, failedHttpObservation, skippedHttpObservation } f
20
20
  import { collectTlsIntelligence, skippedTlsObservation } from "./tls-intelligence.mjs";
21
21
  import { parseRegistryDate, registryDateIso } from "./registry-dates.mjs";
22
22
  import { analyzeWebsiteSecurityPosture } from "./website-security-posture.mjs";
23
+ import { analyzeWebsiteTechnology, captureTechnologyResponseHeaders, } from "./website-technology.mjs";
23
24
  import { analyzeResponsePolicyHeaders, qualifyResponsePolicyWithCspMeta, } from "./response-policy.mjs";
24
25
  import { nonEmptyErrorMessage } from "./error-detail.mjs";
25
26
  import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
@@ -250,15 +251,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
250
251
  status: 'fetched',
251
252
  detail: `Homepage responded over ${scheme.toUpperCase()} (HTTP ${res.status}).`,
252
253
  responsePolicy: analyzeResponsePolicyHeaders(res.headers),
253
- technologyHeaders: {
254
- 'cf-ray': res.headers.get('cf-ray') || '',
255
- 'x-drupal-cache': res.headers.get('x-drupal-cache') || '',
256
- 'x-served-by': res.headers.get('x-served-by') || '',
257
- 'x-powered-by': res.headers.get('x-powered-by') || '',
258
- 'x-shopify-stage': res.headers.get('x-shopify-stage') || '',
259
- 'x-sorting-hat-podid': res.headers.get('x-sorting-hat-podid') || '',
260
- 'x-vercel-id': res.headers.get('x-vercel-id') || '',
261
- },
254
+ technologyHeaders: captureTechnologyResponseHeaders(res.headers),
262
255
  http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
263
256
  previousAttempts: failures,
264
257
  capturedBodyBytes: body.bytesRead,
@@ -280,6 +273,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs =
280
273
  status: 'responded',
281
274
  detail: `Web server responded over ${scheme.toUpperCase()} (HTTP ${res.status}); homepage content was not available for inspection.`,
282
275
  responsePolicy: analyzeResponsePolicyHeaders(res.headers),
276
+ technologyHeaders: captureTechnologyResponseHeaders(res.headers),
283
277
  http: buildHttpObservation({ ...detail, durationMs: Date.now() - attemptStartedAt }, {
284
278
  previousAttempts: failures,
285
279
  capturedBodyBytes: 0,
@@ -675,6 +669,15 @@ async function checkDomainAvailability(domain, options = {}) {
675
669
  });
676
670
  }
677
671
  }
672
+ if (options.includeTechnologyProfile !== false && htmlSignals.technologyProfile === null
673
+ && ['fetched', 'responded'].includes(homepage.status)) {
674
+ htmlSignals.technologyProfile = analyzeWebsiteTechnology({
675
+ htmlAvailable: false,
676
+ httpServer: homepage.http?.response?.server,
677
+ responseHeaders: homepage.technologyHeaders,
678
+ observedAt: homepage.http?.observedAt,
679
+ });
680
+ }
678
681
  const responsePolicy = qualifyResponsePolicyWithCspMeta(homepage.responsePolicy, htmlSignals.cspMetaPolicy);
679
682
  const retainedHttp = options.includeDeliveryMetadata === false
680
683
  ? withoutHttpDeliveryMetadata(homepage.http)
@@ -3,15 +3,8 @@
3
3
  // bounded before a provider or connector result reaches its public model.
4
4
  import { domainToASCII } from 'node:url';
5
5
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
6
- export function isRecord(value) {
7
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
8
- }
9
- export function recordOrNull(value) {
10
- return isRecord(value) ? value : null;
11
- }
12
- export function recordOrEmpty(value) {
13
- return isRecord(value) ? value : {};
14
- }
6
+ import { isRecord } from "./json-record.mjs";
7
+ export { isRecord, recordOrEmpty, recordOrNull } from "./json-record.mjs";
15
8
  export function boundedString(value, maxLength) {
16
9
  if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
17
10
  return null;
@@ -8,6 +8,7 @@ import { Worker } from 'node:worker_threads';
8
8
  import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
9
9
  import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
10
10
  import { createObservation } from "../packages/evidence/observation.mjs";
11
+ import { BROWSER_LIBRARY_PROFILE_VERSION, MAX_LIBRARY_FINDINGS, } from "./lookup-child-profile-contract.mjs";
11
12
  import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
12
13
  // Catalogue expressions are reviewed third-party inputs. Keep every individual
13
14
  // regular-expression evaluation and the aggregate inline-signature work small
@@ -17,9 +18,7 @@ const MAX_INLINE_LIBRARY_SCAN_CHARS = 1_024;
17
18
  const MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS = 4_096;
18
19
  const MAX_INLINE_LIBRARY_SCAN_MS = 750;
19
20
  const MAX_INLINE_LIBRARY_WORKER_BYTES = 64 * 1024;
20
- const BROWSER_LIBRARY_PROFILE_VERSION = 2;
21
21
  const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
22
- const MAX_LIBRARY_FINDINGS = 16;
23
22
  const MAX_ADVISORY_IDENTIFIERS = 16;
24
23
  const MAX_WEAKNESS_CLASSES = 12;
25
24
  const MAX_MATCHES_PER_PATTERN = 4;
@@ -3,9 +3,8 @@
3
3
  // indicator identifiers, descriptions, evidence classes, and bounded counts
4
4
  // leave this module; script references and contents are discarded.
5
5
  import { createObservation } from "../packages/evidence/observation.mjs";
6
+ import { CLIENT_BEHAVIOR_PROFILE_VERSION, MAX_CLIENT_BEHAVIOR_INDICATORS, } from "./lookup-child-profile-contract.mjs";
6
7
  import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
7
- const CLIENT_BEHAVIOR_PROFILE_VERSION = 1;
8
- const MAX_CLIENT_BEHAVIOR_INDICATORS = 12;
9
8
  const MAX_INDICATOR_OCCURRENCES = 999;
10
9
  function boundedOccurrences(value) {
11
10
  return Math.max(1, Math.min(MAX_INDICATOR_OCCURRENCES, value));
@@ -4,8 +4,8 @@
4
4
  // names, values, labels, placeholders, arbitrary attributes, and action URLs
5
5
  // never enter this profile.
6
6
  import { createObservation } from "../packages/evidence/observation.mjs";
7
+ import { CREDENTIAL_SURFACE_PROFILE_VERSION } from "./lookup-child-profile-contract.mjs";
7
8
  import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
8
- const CREDENTIAL_SURFACE_PROFILE_VERSION = 1;
9
9
  function analyzeCredentialSurfaceProfile(input = {}) {
10
10
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html, { baseUrl: input.baseUrl });
11
11
  const forms = htmlAnalysis.forms;
@@ -9,6 +9,13 @@ const SPF_MAX_DEPTH = 5;
9
9
  const SPF_MAX_BRANCHES = 32;
10
10
  const MAX_DMARC_DESTINATIONS = 10;
11
11
  const MAX_DEPENDENCIES = 64;
12
+ const DEPENDENCY_KIND_ORDER = Object.freeze({
13
+ nameserver: 0,
14
+ mail_exchange: 1,
15
+ spf_include: 2,
16
+ spf_redirect: 3,
17
+ dmarc_reporting: 4,
18
+ });
12
19
  function strictHostname(value) {
13
20
  const input = String(value || '').trim().replace(/\.+$/u, '');
14
21
  if (/[^\x00-\x7f]/u.test(input))
@@ -252,6 +259,8 @@ function buildExternalDependencies({ domain, nameservers, mx, spfExpansion, dmar
252
259
  for (const authorization of dmarcAuthorizations) {
253
260
  add('dmarc_reporting', authorization.destination, `DMARC ${authorization.reportType} reporting`, ['authorized', 'self'].includes(authorization.state) ? 'observed' : 'unavailable');
254
261
  }
255
- return output;
262
+ return output.sort((left, right) => (DEPENDENCY_KIND_ORDER[left.kind] - DEPENDENCY_KIND_ORDER[right.kind]
263
+ || left.target.localeCompare(right.target)
264
+ || left.source.localeCompare(right.source)));
256
265
  }
257
266
  export { SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };