@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -1,9 +1,11 @@
1
- import { stat } from 'node:fs/promises';
1
+ import { constants as fsConstants } from 'node:fs';
2
+ import { open, realpath } from 'node:fs/promises';
2
3
  import { isIP } from 'node:net';
3
- import { open } from 'maxmind';
4
+ import { Reader } from 'maxmind';
4
5
  import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
5
6
  import { CliUsageError } from "./errors.mjs";
6
7
  export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
8
+ export const LOCAL_MMDB_QUERY_VERSION = 1;
7
9
  export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
8
10
  function record(value) {
9
11
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
@@ -17,6 +19,43 @@ function names(value) {
17
19
  const source = record(value);
18
20
  return boundedText(source.en, 120);
19
21
  }
22
+ async function readStableMmdb(databasePath) {
23
+ let handle = null;
24
+ try {
25
+ const resolvedPath = await realpath(databasePath);
26
+ handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
27
+ const before = await handle.stat();
28
+ if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
29
+ throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
30
+ }
31
+ const database = Buffer.allocUnsafe(before.size);
32
+ let offset = 0;
33
+ while (offset < database.length) {
34
+ const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
35
+ if (bytesRead === 0)
36
+ break;
37
+ offset += bytesRead;
38
+ }
39
+ const after = await handle.stat();
40
+ if (offset !== database.length
41
+ || before.dev !== after.dev
42
+ || before.ino !== after.ino
43
+ || before.size !== after.size
44
+ || before.mtimeMs !== after.mtimeMs
45
+ || before.ctimeMs !== after.ctimeMs) {
46
+ throw new CliUsageError('The supplied MMDB changed while it was being read.');
47
+ }
48
+ return database;
49
+ }
50
+ catch (cause) {
51
+ if (cause instanceof CliUsageError)
52
+ throw cause;
53
+ throw new CliUsageError('The supplied MMDB file could not be read.');
54
+ }
55
+ finally {
56
+ await handle?.close().catch(() => { });
57
+ }
58
+ }
20
59
  export async function reviewLocalMmdb(input, databasePath) {
21
60
  const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
22
61
  const sourceLabel = boundedText(input.sourceLabel, 120);
@@ -27,21 +66,13 @@ export async function reviewLocalMmdb(input, databasePath) {
27
66
  if (!sourceLabel || !databaseVersion || !license) {
28
67
  throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
29
68
  }
30
- let info;
31
- try {
32
- info = await stat(databasePath);
33
- }
34
- catch {
35
- throw new CliUsageError('The supplied MMDB file could not be read.');
36
- }
37
- if (!info.isFile() || info.size <= 0 || info.size > MAX_LOCAL_MMDB_BYTES) {
38
- throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
39
- }
40
69
  let reader;
41
70
  try {
42
- reader = await open(databasePath, { cache: { max: 1_000 } });
71
+ reader = new Reader(await readStableMmdb(databasePath));
43
72
  }
44
- catch {
73
+ catch (cause) {
74
+ if (cause instanceof CliUsageError)
75
+ throw cause;
45
76
  throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
46
77
  }
47
78
  const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
@@ -84,8 +84,8 @@ async function runLookupCommand(args, dependencies, context) {
84
84
  const evidenceModule = await loadEvidence();
85
85
  const report = buildCliEvidenceExport(JSON.stringify(document), evidenceModule, now);
86
86
  context.writeStdout(args.output === 'markdown'
87
- ? formatLookupEvidenceMarkdown(report)
88
- : formatLookupEvidenceHtml(report));
87
+ ? formatLookupEvidenceMarkdown(report, { includeAttribution: args.includeAttribution })
88
+ : formatLookupEvidenceHtml(report, { includeAttribution: args.includeAttribution }));
89
89
  }
90
90
  else {
91
91
  context.writeStdout(context.terminal(formatTerminalLookup(document, { detail: args.detail }), args.color));
@@ -95,6 +95,9 @@ function lookupComparisonInput(document) {
95
95
  faviconMatch: false,
96
96
  faviconNearMatch: false,
97
97
  reusesOfficialAssets: false,
98
+ idnReferenceMatch: false,
99
+ pageBaselineMatch: false,
100
+ hasActiveBrandProfile: false,
98
101
  hasPasswordField: recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories
99
102
  ? Number(recordOrEmpty(recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories).password) > 0
100
103
  : false,
@@ -115,6 +118,12 @@ function lookupComparisonInput(document) {
115
118
  comparisonEvidence,
116
119
  relationship,
117
120
  sourceCoverage: sourceCoverage(document, availability),
121
+ profileContext: {
122
+ sourceState: 'ready',
123
+ activeProfileId: null,
124
+ profileUpdatedAt: null,
125
+ limitation: '',
126
+ },
118
127
  };
119
128
  }
120
129
  function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
@@ -37,7 +37,7 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
37
37
  label: `Lookup reconciliation observation ${index + 1}`,
38
38
  }));
39
39
  const domain = documents[0]?.registrableDomain;
40
- if (!domain || documents.some((document) => document.registrableDomain !== domain)) {
40
+ if (!domain || documents.some((candidate) => candidate.registrableDomain !== domain)) {
41
41
  throw new CliUsageError('Lookup reconciliation requires observations for one domain.');
42
42
  }
43
43
  const comparisons = documents.slice(1).map((document, index) => buildCliLookupDiff(inputs[0], inputs[index + 1], generatedAt, { domainMode: 'same' }));
@@ -128,14 +128,14 @@ function parseInput(textValue) {
128
128
  try {
129
129
  const parsed = JSON.parse(textInput);
130
130
  const document = record(parsed);
131
- if (document.schema === 'whoisleuth.cli.bulk' && document.version === 2 && Array.isArray(document.results)) {
131
+ if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
132
132
  values = document.results;
133
133
  }
134
- else if (document.schema === 'whoisleuth.cli.bulk.item' && document.version === 2) {
134
+ else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
135
135
  values = [document];
136
136
  }
137
137
  else {
138
- throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2.');
138
+ throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2 or 3.');
139
139
  }
140
140
  }
141
141
  catch (error) {
@@ -152,8 +152,8 @@ function parseInput(textValue) {
152
152
  throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
153
153
  }
154
154
  const rows = values.map((value) => record(value));
155
- if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || value.version !== 2)) {
156
- throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2.');
155
+ if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
156
+ throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
157
157
  }
158
158
  return rows;
159
159
  }
package/cli/manual.mjs CHANGED
@@ -23,7 +23,7 @@ ${commands}
23
23
  .SH OUTPUT
24
24
  Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
25
25
  .SH EXIT STATUS
26
- 0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, and 130 analyst cancellation.
26
+ 0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
27
27
  .SH PRIVACY
28
28
  Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
29
29
  .SH LICENSE
@@ -2,6 +2,8 @@ import { fetchHomepage } from "../lib/availability.mjs";
2
2
  import { searchCertificateTransparency } from "../lib/ct-search.mjs";
3
3
  import { checkDomainPosture, normalizeAuditDomain, normalizeDkimSelectors } from "../lib/domain-posture.mjs";
4
4
  import { collectTlsIntelligence, normalizeTlsHostname } from "../lib/tls-intelligence.mjs";
5
+ import { MAX_DNSSEC_TRUST_ANCHOR_BYTES, formatDnssecChainReport, validateDnssecChain, } from "../lib/dnssec-chain-validation.mjs";
6
+ import { MAX_MAIL_TRANSPORT_INPUT_BYTES, collectMailTransportReview, formatMailTransportReview, } from "../lib/smtp-transport-review.mjs";
5
7
  import { CliUsageError } from "./errors.mjs";
6
8
  import { buildCliCtSearchDocument, buildCliHttpDocument, buildCliPostureDocument, buildCliTlsDocument, formatJsonDocument, } from "./formatters/json.mjs";
7
9
  import { formatTerminalCtSearch, formatTerminalHttp, formatTerminalPosture, formatTerminalTls, } from "./formatters/terminal.mjs";
@@ -71,6 +73,82 @@ async function runNetworkCommand(args, dependencies, context) {
71
73
  }
72
74
  return EXIT_CODES.SUCCESS;
73
75
  }
76
+ if (args.action === 'dnssec-validate') {
77
+ let anchorInput;
78
+ try {
79
+ anchorInput = dependencies.readTrustAnchorInput
80
+ ? await dependencies.readTrustAnchorInput(args.trustAnchorSource)
81
+ : await context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor');
82
+ }
83
+ catch (error) {
84
+ if (error instanceof CliUsageError)
85
+ throw error;
86
+ throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
87
+ }
88
+ const validate = dependencies.validateDnssecChain ?? validateDnssecChain;
89
+ const report = await context.withProgress('Validating the isolated DNSSEC chain', () => validate({
90
+ target: args.target,
91
+ resolver: args.resolver,
92
+ trustAnchor: anchorInput,
93
+ observedAt: context.now(),
94
+ ownedOrAuthorized: args.ownedOrAuthorized,
95
+ }));
96
+ if (!args.quiet) {
97
+ context.writeStdout(args.output === 'json'
98
+ ? formatJsonDocument(report)
99
+ : context.terminal(formatDnssecChainReport(report), args.color));
100
+ }
101
+ return report.state === 'secure' || report.state === 'insecure'
102
+ ? EXIT_CODES.SUCCESS
103
+ : EXIT_CODES.PARTIAL_FAILURE;
104
+ }
105
+ if (args.action === 'mail-transport') {
106
+ let input;
107
+ let anchorInput;
108
+ try {
109
+ input = await (dependencies.readMailTransportInput
110
+ ? dependencies.readMailTransportInput(args.source)
111
+ : context.readInput(args.source, MAX_MAIL_TRANSPORT_INPUT_BYTES, 'Mail transport input'));
112
+ }
113
+ catch (error) {
114
+ if (error instanceof CliUsageError)
115
+ throw error;
116
+ throw new CliUsageError(`Could not read mail transport input: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
117
+ }
118
+ try {
119
+ anchorInput = await (dependencies.readTrustAnchorInput
120
+ ? dependencies.readTrustAnchorInput(args.trustAnchorSource)
121
+ : context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor'));
122
+ }
123
+ catch (error) {
124
+ if (error instanceof CliUsageError)
125
+ throw error;
126
+ throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
127
+ }
128
+ if (!input.trim())
129
+ throw new CliUsageError('mail-transport requires one versioned JSON file or a document on stdin.');
130
+ const collect = dependencies.collectMailTransportReview ?? collectMailTransportReview;
131
+ let review;
132
+ try {
133
+ review = await context.withProgress('Reviewing selected authorised mail transports', () => collect(input, {
134
+ resolver: args.resolver,
135
+ trustAnchor: anchorInput,
136
+ ownedOrAuthorized: args.ownedOrAuthorized,
137
+ activeProbeAcknowledged: args.activeProbeAcknowledged,
138
+ }));
139
+ }
140
+ catch (error) {
141
+ if (error instanceof TypeError)
142
+ throw new CliUsageError(error.message);
143
+ throw error;
144
+ }
145
+ if (!args.quiet) {
146
+ context.writeStdout(args.output === 'json'
147
+ ? formatJsonDocument(review)
148
+ : context.terminal(formatMailTransportReview(review), args.color));
149
+ }
150
+ return review.runState === 'complete' ? EXIT_CODES.SUCCESS : EXIT_CODES.PARTIAL_FAILURE;
151
+ }
74
152
  const requestedHostname = args.hostname || await context.readSingleInput();
75
153
  if (!requestedHostname)
76
154
  throw new CliUsageError('tls requires one hostname as an argument or on stdin.');
@@ -8,6 +8,7 @@ import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-e
8
8
  import { inspectRdapReverseSearchResponse, normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
9
9
  import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
10
10
  import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
11
+ import { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, buildCryptographicAssuranceReview, } from "../lib/cryptographic-assurance.mjs";
11
12
  import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
12
13
  import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
13
14
  import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
@@ -16,6 +17,13 @@ import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mj
16
17
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
17
18
  const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
18
19
  const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
20
+ const RDAP_SEARCH_INPUT_SCHEMA = 'whoisleuth.rdap-search-input';
21
+ const DNSSEC_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.dnssec-evidence-input';
22
+ const TLSA_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.tlsa-evidence-input';
23
+ const RPKI_ROUTE_INPUT_SCHEMA = 'whoisleuth.rpki-route-input';
24
+ const LOCAL_GEOIP_QUERY_SCHEMA = 'whoisleuth.local-geoip-query';
25
+ const ENCRYPTED_DNS_PLAN_INPUT_SCHEMA = 'whoisleuth.encrypted-dns-plan-input';
26
+ const OFFLINE_EVIDENCE_INPUT_VERSION = 1;
19
27
  function record(value) {
20
28
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
21
29
  }
@@ -40,7 +48,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
40
48
  const input = parseInput(value);
41
49
  let kind;
42
50
  let result;
43
- if (input.schema === 'whoisleuth.rdap-search-input') {
51
+ if (input.schema === RDAP_SEARCH_INPUT_SCHEMA) {
44
52
  kind = 'rdap_search';
45
53
  const request = record(input.request);
46
54
  const help = normalizeRdapSearchHelp(input.help);
@@ -58,7 +66,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
58
66
  : inspectRdapReverseSearchResponse(input.response, [request.property]),
59
67
  });
60
68
  }
61
- else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
69
+ else if (input.schema === DNSSEC_EVIDENCE_INPUT_SCHEMA) {
62
70
  kind = 'dnssec';
63
71
  result = validateDnssecEvidence({
64
72
  ownerName: input.ownerName,
@@ -69,7 +77,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
69
77
  observedAt: input.observedAt,
70
78
  });
71
79
  }
72
- else if (input.schema === 'whoisleuth.tlsa-evidence-input') {
80
+ else if (input.schema === TLSA_EVIDENCE_INPUT_SCHEMA) {
73
81
  kind = 'tlsa';
74
82
  result = analyzeTlsaEvidence({
75
83
  serviceName: input.serviceName,
@@ -81,7 +89,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
81
89
  authorityMaterials: input.authorityMaterials,
82
90
  });
83
91
  }
84
- else if (input.schema === 'whoisleuth.rpki-route-input') {
92
+ else if (input.schema === RPKI_ROUTE_INPUT_SCHEMA) {
85
93
  kind = 'rpki';
86
94
  result = reviewRpkiRoute({
87
95
  routePrefix: input.routePrefix,
@@ -89,11 +97,15 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
89
97
  authorizations: input.authorizations,
90
98
  });
91
99
  }
92
- else if (input.schema === 'whoisleuth.local-geoip-query') {
100
+ else if (input.schema === CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA) {
101
+ kind = 'cryptographic_assurance';
102
+ result = buildCryptographicAssuranceReview(input, generatedAt);
103
+ }
104
+ else if (input.schema === LOCAL_GEOIP_QUERY_SCHEMA) {
93
105
  kind = 'geoip';
94
106
  result = lookupLocalGeoIp(buildLocalGeoIpDatabase(input.database), input.address);
95
107
  }
96
- else if (input.schema === 'whoisleuth.encrypted-dns-plan-input') {
108
+ else if (input.schema === ENCRYPTED_DNS_PLAN_INPUT_SCHEMA) {
97
109
  kind = 'encrypted_dns';
98
110
  const query = record(input.query);
99
111
  result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
@@ -168,7 +180,8 @@ function formatOfflineEvidenceReview(document) {
168
180
  : 'review'
169
181
  : typeof gate.pass === 'boolean'
170
182
  ? gate.pass ? 'ready' : 'review'
171
- : document.kind === 'domain_portfolio' ? 'inventory' : 'reviewed';
183
+ : document.kind === 'domain_portfolio' ? 'inventory'
184
+ : document.kind === 'cryptographic_assurance' ? 'separate' : 'reviewed';
172
185
  const lines = [
173
186
  'Offline evidence review',
174
187
  `Kind ${document.kind.replaceAll('_', ' ')}`,
@@ -220,6 +233,13 @@ function formatOfflineEvidenceReview(document) {
220
233
  lines.push(`Recovery ${listLength(result.recoveryCycles)}`);
221
234
  lines.push(`Unknown ${Object.values(unknownCounts).reduce((total, value) => total + count(value), 0)}`);
222
235
  }
236
+ else if (document.kind === 'cryptographic_assurance') {
237
+ const cards = Array.isArray(result.cards) ? result.cards : [];
238
+ for (const value of cards) {
239
+ const card = record(value);
240
+ lines.push(`${String(card.label ?? card.family ?? 'Evidence')} ${String(card.state ?? 'unavailable').replaceAll('_', ' ')} (${String(card.completeness ?? 'unavailable')})`);
241
+ }
242
+ }
223
243
  else {
224
244
  for (const [label, key] of [
225
245
  ['Records', 'records'],
@@ -253,4 +273,4 @@ function formatOfflineEvidenceReview(document) {
253
273
  }
254
274
  return `${lines.join('\n')}\n`;
255
275
  }
256
- export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
276
+ export { DNSSEC_EVIDENCE_INPUT_SCHEMA, ENCRYPTED_DNS_PLAN_INPUT_SCHEMA, LOCAL_GEOIP_QUERY_SCHEMA, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_INPUT_VERSION, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, RDAP_SEARCH_INPUT_SCHEMA, RPKI_ROUTE_INPUT_SCHEMA, TLSA_EVIDENCE_INPUT_SCHEMA, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
@@ -4,7 +4,7 @@ import { comparePageBaselines } from "../frontend/src/lib/analysis/page-similari
4
4
  import { CliUsageError } from "./errors.mjs";
5
5
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
6
6
  export const CLI_PAGE_COMPARE_SCHEMA = 'whoisleuth.cli.page-compare';
7
- export const CLI_PAGE_COMPARE_VERSION = 2;
7
+ export const CLI_PAGE_COMPARE_VERSION = 3;
8
8
  function record(value) {
9
9
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
10
  }
@@ -56,6 +56,8 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
56
56
  && ['success', 'partial'].includes(right.compact.technology.state);
57
57
  const tlsUsable = ['success', 'partial'].includes(left.compact.tls.state)
58
58
  && ['success', 'partial'].includes(right.compact.tls.state);
59
+ const technologyPartial = left.compact.technology.truncated || right.compact.technology.truncated
60
+ || left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial';
59
61
  return {
60
62
  schema: CLI_PAGE_COMPARE_SCHEMA,
61
63
  version: CLI_PAGE_COMPARE_VERSION,
@@ -72,14 +74,15 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
72
74
  },
73
75
  page,
74
76
  technology: {
75
- state: technologyUsable ? setComparison(leftTechnology, rightTechnology) : 'unavailable',
77
+ state: technologyUsable
78
+ ? technologyPartial ? 'partial' : setComparison(leftTechnology, rightTechnology)
79
+ : 'unavailable',
76
80
  leftSourceState: left.compact.technology.state,
77
81
  rightSourceState: right.compact.technology.state,
78
82
  leftIds: leftTechnology,
79
83
  rightIds: rightTechnology,
80
84
  sharedIds: sharedTechnology,
81
- partial: left.compact.technology.truncated || right.compact.technology.truncated
82
- || left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial',
85
+ partial: technologyPartial,
83
86
  },
84
87
  tls: {
85
88
  leftSourceState: left.compact.tls.state,
@@ -115,7 +118,7 @@ function formatCliPageComparison(document) {
115
118
  for (const component of document.page.components) {
116
119
  lines.push(`${component.label.padEnd(18)} ${component.outcome}`);
117
120
  }
118
- lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
121
+ lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Evidence ${document.technology.partial ? 'Partial; equality and disjointness withheld' : 'Complete retained sets'}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
119
122
  for (const limitation of document.limitations)
120
123
  lines.push(` - ${limitation}`);
121
124
  return `${lines.join('\n')}\n`;