@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -1,10 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { canonicalArtifactJson, sha256ArtifactDigest, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { recordOrNull, requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
3
+ import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
5
5
  import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
6
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
7
  export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
7
- export const INVESTIGATION_MANIFEST_VERSION = 1;
8
+ export const INVESTIGATION_MANIFEST_VERSION = 2;
8
9
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
9
10
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
10
11
  export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
@@ -40,20 +41,17 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
40
41
  if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
41
42
  throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
42
43
  }
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(artifact.content);
44
+ if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
45
+ throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
46
46
  }
47
- catch {
48
- throw new TypeError(`Artifact ${index + 1} must be valid JSON.`);
49
- }
50
- const value = recordOrNull(parsed);
51
- if (!value)
52
- throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
47
+ const value = parseBoundedJsonObject(artifact.content, {
48
+ label: `Artifact ${index + 1}`,
49
+ maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES,
50
+ });
53
51
  const schema = value.schema === undefined
54
52
  ? null
55
53
  : requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
56
- const canonical = canonicalArtifactJson(value);
54
+ const canonical = canonicalArtifactJsonV2(value);
57
55
  const contentDigestSha256 = digest(artifact.content);
58
56
  return Object.freeze({
59
57
  sequence: index + 1,
@@ -65,9 +63,6 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
65
63
  canonicalDigestSha256: digest(canonical),
66
64
  });
67
65
  });
68
- if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
69
- throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
70
- }
71
66
  const unsigned = Object.freeze({
72
67
  schema: INVESTIGATION_MANIFEST_SCHEMA,
73
68
  version: INVESTIGATION_MANIFEST_VERSION,
@@ -90,7 +85,11 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
90
85
  });
91
86
  return Object.freeze({
92
87
  ...unsigned,
93
- integrity: Object.freeze({ algorithm: 'SHA-256', digestSha256: await sha256ArtifactDigest(unsigned) }),
88
+ integrity: Object.freeze({
89
+ algorithm: 'SHA-256',
90
+ canonicalization: SORTED_JSON_V2,
91
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
92
+ }),
94
93
  });
95
94
  }
96
95
  export function formatInvestigationManifest(manifest) {
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { buildInvestigationPlan, } from "./investigation-plan.mjs";
3
3
  import { CliUsageError } from "./errors.mjs";
4
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
5
  export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
5
6
  export const CLI_INVESTIGATION_RUN_VERSION = 1;
6
7
  export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
@@ -9,50 +10,93 @@ function boundedResult(value) {
9
10
  throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
10
11
  }
11
12
  try {
13
+ scanBoundedJson(value);
12
14
  return JSON.parse(value);
13
15
  }
14
- catch {
16
+ catch (cause) {
17
+ if (cause instanceof TypeError && cause.message !== 'Artefact input is not valid JSON.') {
18
+ throw new CliUsageError(`Investigation step output ${cause.message.replace(/^Artefact JSON /u, '')}`);
19
+ }
15
20
  return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
16
21
  }
17
22
  }
18
- function parseResumeState(input, recipe, subject) {
23
+ function resultSchema(value) {
24
+ return value && typeof value === 'object' && !Array.isArray(value) && typeof value.schema === 'string'
25
+ ? String(value.schema)
26
+ : null;
27
+ }
28
+ function sameArguments(left, right) {
29
+ return Array.isArray(left)
30
+ && left.length === right.length
31
+ && left.every((value, index) => typeof value === 'string' && value === right[index]);
32
+ }
33
+ function parseResumeState(input, plan) {
19
34
  if (!input)
20
35
  return [];
21
36
  if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
22
37
  throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
38
+ const normalizedInput = input.replace(/^\uFEFF/u, '');
39
+ try {
40
+ scanBoundedJson(normalizedInput);
41
+ }
42
+ catch (cause) {
43
+ const detail = cause instanceof Error ? cause.message : '';
44
+ if (detail === 'Artefact input is not valid JSON.')
45
+ throw new CliUsageError('Investigation resume state must be valid JSON.');
46
+ throw new CliUsageError(detail ? `Investigation resume state ${detail.replace(/^Artefact JSON /u, '')}` : 'Investigation resume state must be valid JSON.');
47
+ }
23
48
  let parsed;
24
49
  try {
25
- parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
50
+ parsed = JSON.parse(normalizedInput);
26
51
  }
27
52
  catch {
28
53
  throw new CliUsageError('Investigation resume state must be valid JSON.');
29
54
  }
30
55
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
31
- if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== recipe || root.subject !== subject || !Array.isArray(root.completedSteps)) {
56
+ if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
32
57
  throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
33
58
  }
34
- return root.completedSteps.slice(0, 16).flatMap((value) => {
35
- if (!value || typeof value !== 'object' || Array.isArray(value))
36
- return [];
59
+ if (root.completedSteps.length > plan.steps.length) {
60
+ throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
61
+ }
62
+ const completed = [];
63
+ for (const [index, value] of root.completedSteps.entries()) {
64
+ const planned = plan.steps[index];
65
+ if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
66
+ throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
67
+ }
37
68
  const item = value;
38
- if (typeof item.id !== 'string' || typeof item.command !== 'string' || !Array.isArray(item.arguments) || !['offline', 'network'].includes(String(item.mode)) || !Number.isSafeInteger(item.exitCode))
39
- return [];
40
- return [Object.freeze({
41
- id: item.id,
42
- command: item.command,
43
- arguments: Object.freeze(item.arguments.filter((entry) => typeof entry === 'string').slice(0, 32)),
44
- mode: item.mode,
45
- exitCode: Number(item.exitCode),
46
- result: item.result,
47
- })];
48
- });
69
+ if (item.id !== planned.id
70
+ || item.command !== planned.command
71
+ || item.mode !== planned.mode
72
+ || !sameArguments(item.arguments, planned.arguments)
73
+ || !Number.isSafeInteger(item.exitCode)) {
74
+ throw new CliUsageError('Investigation resume state does not match the installed fixed recipe.');
75
+ }
76
+ const exitCode = Number(item.exitCode);
77
+ if (exitCode !== 0 && exitCode !== 2) {
78
+ if (index !== root.completedSteps.length - 1) {
79
+ throw new CliUsageError('A failed investigation step must be the final retained step.');
80
+ }
81
+ break;
82
+ }
83
+ if (resultSchema(item.result) !== planned.produces) {
84
+ throw new CliUsageError('Investigation resume state contains output from an unexpected command contract.');
85
+ }
86
+ completed.push(Object.freeze({
87
+ id: planned.id,
88
+ command: planned.command,
89
+ arguments: planned.arguments,
90
+ mode: planned.mode,
91
+ exitCode,
92
+ result: item.result,
93
+ }));
94
+ }
95
+ return completed;
49
96
  }
50
97
  export async function runInvestigationRecipe(recipe, subjectValue, options) {
51
98
  const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
52
- const prior = parseResumeState(options.resumeInput, recipe, plan.subject);
53
- const allowedStepIds = new Set(plan.steps.map((step) => step.id));
54
- if (prior.some((step) => !allowedStepIds.has(step.id)))
55
- throw new CliUsageError('Investigation resume state contains a step outside the fixed recipe.');
99
+ const prior = parseResumeState(options.resumeInput, plan);
56
100
  const completed = [...prior];
57
101
  let state = 'complete';
58
102
  let currentStep = null;
@@ -69,13 +113,17 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
69
113
  break;
70
114
  }
71
115
  const result = await options.execute(step.command, step.arguments);
116
+ const parsedResult = boundedResult(result.stdout);
117
+ if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
118
+ throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
119
+ }
72
120
  completed.push(Object.freeze({
73
121
  id: step.id,
74
122
  command: step.command,
75
123
  arguments: step.arguments,
76
124
  mode: step.mode,
77
125
  exitCode: result.exitCode,
78
- result: boundedResult(result.stdout),
126
+ result: parsedResult,
79
127
  }));
80
128
  if (result.exitCode !== 0 && result.exitCode !== 2) {
81
129
  state = 'step_failed';
@@ -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 = {}) {
@@ -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`;