@slicedearth/whoisleuth-cli 1.36.1 → 1.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +17 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +236 -5
  4. package/cli/artifact-verify.mjs +56 -14
  5. package/cli/compare.mjs +1 -1
  6. package/cli/completion.mjs +18 -3
  7. package/cli/doctor.mjs +11 -5
  8. package/cli/evidence-command-arguments.mjs +1 -1
  9. package/cli/evidence-command-runner.mjs +2 -2
  10. package/cli/evidence-signing.mjs +4 -4
  11. package/cli/formatters/html.mjs +1 -1
  12. package/cli/formatters/json.mjs +13 -1
  13. package/cli/formatters/terminal.mjs +7 -3
  14. package/cli/investigation-plan.mjs +2 -2
  15. package/cli/local-mmdb-review.mjs +76 -0
  16. package/cli/lookalike-calibration.mjs +121 -0
  17. package/cli/lookup-command-runner.mjs +4 -1
  18. package/cli/lookup-plan.mjs +3 -3
  19. package/cli/lookup-reconcile.mjs +141 -0
  20. package/cli/lookup-timeline.mjs +1 -1
  21. package/cli/mail-review.mjs +1 -1
  22. package/cli/offline-evidence-review.mjs +20 -2
  23. package/cli/registry-doctor.mjs +125 -0
  24. package/cli/risk-calibration.mjs +117 -9
  25. package/cli/runner.mjs +236 -17
  26. package/cli/saved-lookup.mjs +2 -2
  27. package/cli/sharing-review.mjs +202 -0
  28. package/cli/source-reliability.mjs +1 -1
  29. package/docs/cli-reference.md +1110 -0
  30. package/docs/cli.md +224 -940
  31. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  32. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  33. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  34. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  35. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  36. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  37. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  38. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  39. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  40. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  41. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  42. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  43. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  44. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  45. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  46. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  47. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  48. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  49. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  50. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  51. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  52. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  53. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  54. package/frontend/src/lib/analysis/scoring.js +8 -88
  55. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  56. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  57. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  58. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  59. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  60. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  61. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  62. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  63. package/lib/analyst-taxonomy.mjs +40 -0
  64. package/lib/availability.mjs +1 -1
  65. package/lib/browser-library-profile.mjs +15 -2
  66. package/lib/certificate-extension-profile.mjs +71 -0
  67. package/lib/credential-surface-profile.mjs +1 -1
  68. package/lib/domain-assurance.mjs +368 -0
  69. package/lib/domain-control-manifest.mjs +50 -7
  70. package/lib/domain-posture.mjs +9 -9
  71. package/lib/encrypted-dns-contract.mjs +1 -1
  72. package/lib/evidence-report-markdown.mjs +1 -1
  73. package/lib/evidence-report.mjs +1 -1
  74. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  75. package/lib/ip-prefix.mjs +34 -1
  76. package/lib/local-geoip-evidence.mjs +2 -2
  77. package/lib/lookup-response-contract.mjs +346 -0
  78. package/lib/observed-network-context.mjs +1 -1
  79. package/lib/opportunity-scoring.mjs +107 -0
  80. package/lib/page-fingerprints.mjs +1 -1
  81. package/lib/rdap-capabilities.mjs +6 -6
  82. package/lib/rdap-search-workbench.mjs +96 -6
  83. package/lib/registry-access.mjs +1 -1
  84. package/lib/registry-capability-catalogue.mjs +4 -4
  85. package/lib/registry-insights.mjs +6 -6
  86. package/lib/risk-scoring.mjs +206 -83
  87. package/lib/scoring-evidence-quality.mjs +107 -0
  88. package/lib/security-txt.mjs +3 -3
  89. package/lib/threat-intelligence-manifests.mjs +1 -1
  90. package/lib/threatfox-intelligence.mjs +1 -1
  91. package/lib/tls-intelligence.mjs +13 -3
  92. package/lib/urlhaus-intelligence.mjs +1 -1
  93. package/lib/website-security-posture.mjs +5 -5
  94. package/lib/website-technology.mjs +1 -1
  95. package/lib/whois-chain.mjs +1 -1
  96. package/package.json +5 -1
  97. package/third-party-notices.txt +911 -19
package/README.md CHANGED
@@ -50,6 +50,7 @@ node bin/whoisleuth.mts --help
50
50
  node bin/whoisleuth.mts lookup example.test
51
51
  node bin/whoisleuth.mts lookup example.test --deep --summary
52
52
  node bin/whoisleuth.mts lookup example.test --deep --plan --json
53
+ node bin/whoisleuth.mts lookup example.test --deep --observer workstation-a --vantage office-egress --json
53
54
  node bin/whoisleuth.mts lookup example.test --deep --markdown --output lookup.md
54
55
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
55
56
  node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
@@ -59,10 +60,15 @@ node bin/whoisleuth.mts discover-scan example.test --scan-limit 50 --checkpoint
59
60
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
60
61
  node bin/whoisleuth.mts mail-review bulk.json --json
61
62
  node bin/whoisleuth.mts domain-control domain-control-input.json --json
63
+ node bin/whoisleuth.mts assurance domain-assurance-input.json --json
64
+ node bin/whoisleuth.mts sharing-review response-packet.json --marking amber --recipient-scope organization --purpose 'Reviewed handoff' --human-reviewed --personal-data-reviewed --redactions-confirmed --json
62
65
  node bin/whoisleuth.mts workflow-plan domain-triage example.test --json
63
66
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
67
+ node bin/whoisleuth.mts reconcile office.json mobile.json external.json --json
64
68
  node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
65
69
  node bin/whoisleuth.mts registry-support example.test --json
70
+ node bin/whoisleuth.mts registry-doctor lookup.json --json
71
+ node bin/whoisleuth.mts lookalike-calibrate reviewed-candidates.json --json
66
72
  node bin/whoisleuth.mts doctor
67
73
  node bin/whoisleuth.mts commands --json
68
74
  node bin/whoisleuth.mts completion zsh
@@ -97,10 +103,18 @@ cleanup before exiting immediately.
97
103
 
98
104
  The offline `timeline` command orders 2 to 20 saved Lookup documents for one
99
105
  domain and compares each adjacent observation. It retains observation times and
100
- normalized field states but no input filenames or raw registry payloads.
106
+ normalised field states but no input filenames or raw registry payloads.
101
107
  Observed differences remain separate from missing, unavailable, and changed
102
108
  collection conditions.
103
109
 
110
+ Optional bounded observer and vantage labels let analysts distinguish saved
111
+ collection contexts without changing collection. `reconcile` compares 2 to 5
112
+ same-domain observations while keeping agreement, disagreement, and
113
+ non-comparable evidence separate; the labels are context, not proof of
114
+ independence. `registry-doctor`, `assurance`, `sharing-review`, and
115
+ `lookalike-calibrate` add offline compatibility, change/recovery, sharing, and
116
+ review-yield workflows without contacting the hosted application.
117
+
104
118
  Bulk also supports fixed-column CSV, domain-only or exact-query output,
105
119
  registered, inconclusive, and hard-failure output filters, and bounded A, AAAA,
106
120
  NS, and MX summaries. Discover
@@ -115,7 +129,8 @@ lookups can be compared across static page, parse5-tokenized DOM structure,
115
129
  favicon, technology, and TLS evidence, while saved Bulk output can be reviewed
116
130
  for passive MX, null MX, SPF, DMARC, and shared-provider context.
117
131
 
118
- See `docs/cli.md` in the package for the complete command, privacy, output, and
132
+ See `docs/cli.md` in the package for installation and first use, and
133
+ `docs/cli-reference.md` for the complete command, privacy, output, and
119
134
  evidence-contract reference. Report suspected vulnerabilities through the
120
135
  private channel described in `SECURITY.md`.
121
136
 
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { boundedCliErrorMessage } from "../cli/errors.mjs";
3
- import { cleanupPendingOutputFilesSync } from "../cli/output-file.mjs";
4
- import { runCli } from "../cli/runner.mjs";
2
+ import { createRequire } from 'node:module';
5
3
  for (const stream of [process.stdout, process.stderr]) {
6
4
  stream.on('error', (error) => {
7
5
  if (error.code === 'EPIPE')
@@ -9,27 +7,46 @@ for (const stream of [process.stdout, process.stderr]) {
9
7
  throw error;
10
8
  });
11
9
  }
12
- const cancellation = new AbortController();
13
- let interruptionCount = 0;
14
- const interrupt = () => {
15
- interruptionCount += 1;
16
- if (interruptionCount === 1)
17
- cancellation.abort(new DOMException('Aborted', 'AbortError'));
10
+ const argv = process.argv.slice(2);
11
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-V')) {
12
+ const require = createRequire(import.meta.url);
13
+ const metadata = require('../package.json');
14
+ if (typeof metadata.version !== 'string' || !metadata.version) {
15
+ process.stderr.write('Internal CLI error: Package version is unavailable.\n');
16
+ process.exitCode = 70;
17
+ }
18
18
  else {
19
- cleanupPendingOutputFilesSync();
20
- process.exit(130);
19
+ process.stdout.write(`${metadata.version}\n`);
21
20
  }
22
- };
23
- process.on('SIGINT', interrupt);
24
- runCli(process.argv.slice(2), { signal: cancellation.signal }).then((code) => {
25
- if (code === 130) {
21
+ }
22
+ else {
23
+ const [errorsModule, outputModule, runnerModule] = await Promise.all([
24
+ import("../cli/errors.mjs"),
25
+ import("../cli/output-file.mjs"),
26
+ import("../cli/runner.mjs"),
27
+ ]);
28
+ const cancellation = new AbortController();
29
+ let interruptionCount = 0;
30
+ const interrupt = () => {
31
+ interruptionCount += 1;
32
+ if (interruptionCount === 1)
33
+ cancellation.abort(new DOMException('Aborted', 'AbortError'));
34
+ else {
35
+ outputModule.cleanupPendingOutputFilesSync();
36
+ process.exit(130);
37
+ }
38
+ };
39
+ process.on('SIGINT', interrupt);
40
+ runnerModule.runCli(argv, { signal: cancellation.signal }).then((code) => {
41
+ if (code === 130) {
42
+ process.removeListener('SIGINT', interrupt);
43
+ process.exit(130);
44
+ }
45
+ process.exitCode = code;
46
+ }).catch((error) => {
47
+ process.stderr.write(`Internal CLI error: ${errorsModule.boundedCliErrorMessage(error)}\n`);
48
+ process.exitCode = 70;
49
+ }).finally(() => {
26
50
  process.removeListener('SIGINT', interrupt);
27
- process.exit(130);
28
- }
29
- process.exitCode = code;
30
- }).catch((error) => {
31
- process.stderr.write(`Internal CLI error: ${boundedCliErrorMessage(error)}\n`);
32
- process.exitCode = 70;
33
- }).finally(() => {
34
- process.removeListener('SIGINT', interrupt);
35
- });
51
+ });
52
+ }
package/cli/arguments.mjs CHANGED
@@ -17,7 +17,9 @@ const CLI_COMMANDS = [
17
17
  'http',
18
18
  'tls',
19
19
  'registry-support',
20
+ 'registry-doctor',
20
21
  'risk-calibrate',
22
+ 'lookalike-calibrate',
21
23
  'verify-artifact',
22
24
  'inspect-archive',
23
25
  'sign-artifact',
@@ -28,8 +30,11 @@ const CLI_COMMANDS = [
28
30
  'mail-review',
29
31
  'review-evidence',
30
32
  'domain-control',
33
+ 'assurance',
34
+ 'sharing-review',
31
35
  'workflow-plan',
32
36
  'diff',
37
+ 'reconcile',
33
38
  'timeline',
34
39
  'export',
35
40
  ];
@@ -132,8 +137,12 @@ function parseCliArgumentsCore(argv) {
132
137
  return parseTlsArguments(argv.slice(1));
133
138
  if (command === 'registry-support')
134
139
  return parseRegistrySupportArguments(argv.slice(1));
140
+ if (command === 'registry-doctor')
141
+ return parseRegistryDoctorArguments(argv.slice(1));
135
142
  if (command === 'risk-calibrate')
136
143
  return parseRiskCalibrateArguments(argv.slice(1));
144
+ if (command === 'lookalike-calibrate')
145
+ return parseLookalikeCalibrateArguments(argv.slice(1));
137
146
  if (command === 'verify-artifact')
138
147
  return parseVerifyArtifactArguments(argv.slice(1));
139
148
  if (command === 'inspect-archive')
@@ -154,10 +163,16 @@ function parseCliArgumentsCore(argv) {
154
163
  return parseReviewEvidenceArguments(argv.slice(1));
155
164
  if (command === 'domain-control')
156
165
  return parseDomainControlArguments(argv.slice(1));
166
+ if (command === 'assurance')
167
+ return parseAssuranceArguments(argv.slice(1));
168
+ if (command === 'sharing-review')
169
+ return parseSharingReviewArguments(argv.slice(1));
157
170
  if (command === 'workflow-plan')
158
171
  return parseWorkflowPlanArguments(argv.slice(1));
159
172
  if (command === 'diff')
160
173
  return parseDiffArguments(argv.slice(1));
174
+ if (command === 'reconcile')
175
+ return parseReconcileArguments(argv.slice(1));
161
176
  if (command === 'timeline')
162
177
  return parseTimelineArguments(argv.slice(1));
163
178
  if (command === 'export')
@@ -173,7 +188,13 @@ function parseCliArgumentsCore(argv) {
173
188
  let strictExit = false;
174
189
  let events = false;
175
190
  let plan = false;
176
- for (const argument of argv.slice(1)) {
191
+ let observerLabel = null;
192
+ let vantageLabel = null;
193
+ const lookupArguments = argv.slice(1);
194
+ for (let index = 0; index < lookupArguments.length; index++) {
195
+ const argument = lookupArguments[index];
196
+ if (argument === undefined)
197
+ break;
177
198
  if (argument === '--json') {
178
199
  if (output !== 'terminal')
179
200
  throw new CliUsageError('Choose only one output format.');
@@ -217,6 +238,22 @@ function parseCliArgumentsCore(argv) {
217
238
  throw new CliUsageError('--plan may be supplied only once.');
218
239
  plan = true;
219
240
  }
241
+ else if (argument === '--observer' || argument === '--vantage') {
242
+ const isObserver = argument === '--observer';
243
+ if (isObserver ? observerLabel !== null : vantageLabel !== null) {
244
+ throw new CliUsageError(`${argument} may be supplied only once.`);
245
+ }
246
+ const value = lookupArguments[++index];
247
+ if (!value || value.startsWith('-'))
248
+ throw new CliUsageError(`${argument} requires one bounded label.`);
249
+ const normalized = value.replace(/\s+/gu, ' ').trim();
250
+ if (!normalized || normalized.length > 80)
251
+ throw new CliUsageError(`${argument} requires a label of at most 80 characters.`);
252
+ if (isObserver)
253
+ observerLabel = normalized;
254
+ else
255
+ vantageLabel = normalized;
256
+ }
220
257
  else if (argument === '--quiet')
221
258
  quiet = true;
222
259
  else if (argument === '--no-color')
@@ -237,7 +274,7 @@ function parseCliArgumentsCore(argv) {
237
274
  if (plan && (detailSet || strictExit || events || quiet)) {
238
275
  throw new CliUsageError('--plan cannot be combined with detail, strict-exit, event, or quiet options.');
239
276
  }
240
- return { action: 'lookup', query, output, deep, detail, strictExit, events, plan, quiet, color };
277
+ return { action: 'lookup', query, output, deep, detail, strictExit, events, plan, observerLabel, vantageLabel, quiet, color };
241
278
  }
242
279
  function parseCompletionArguments(argv) {
243
280
  if (argv.length !== 1 || !['bash', 'zsh', 'fish', 'powershell'].includes(argv[0] || '')) {
@@ -905,15 +942,25 @@ function parseMailReviewArguments(argv) {
905
942
  }
906
943
  function parseReviewEvidenceArguments(argv) {
907
944
  let source = null;
945
+ let mmdbSource = null;
908
946
  let output = 'terminal';
909
947
  let quiet = false;
910
948
  let color = true;
911
- for (const argument of argv) {
949
+ for (let index = 0; index < argv.length; index += 1) {
950
+ const argument = argv[index];
951
+ if (argument === undefined)
952
+ break;
912
953
  if (argument === '--json') {
913
954
  if (output !== 'terminal')
914
955
  throw new CliUsageError('--json may be supplied only once.');
915
956
  output = 'json';
916
957
  }
958
+ else if (argument === '--mmdb') {
959
+ const value = argv[++index];
960
+ if (!value || value.startsWith('-') || mmdbSource !== null)
961
+ throw new CliUsageError('--mmdb requires one database file and may be supplied only once.');
962
+ mmdbSource = value;
963
+ }
917
964
  else if (argument === '--quiet')
918
965
  quiet = true;
919
966
  else if (argument === '--no-color')
@@ -927,7 +974,7 @@ function parseReviewEvidenceArguments(argv) {
927
974
  }
928
975
  if (quiet && output !== 'terminal')
929
976
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
930
- return { action: 'review-evidence', source, output, quiet, color };
977
+ return { action: 'review-evidence', source, mmdbSource, output, quiet, color };
931
978
  }
932
979
  function parseDomainControlArguments(argv) {
933
980
  let source = null;
@@ -955,6 +1002,108 @@ function parseDomainControlArguments(argv) {
955
1002
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
956
1003
  return { action: 'domain-control', source, output, quiet, color };
957
1004
  }
1005
+ function parseAssuranceArguments(argv) {
1006
+ let source = null;
1007
+ let output = 'terminal';
1008
+ let quiet = false;
1009
+ let color = true;
1010
+ for (const argument of argv) {
1011
+ if (argument === '--json') {
1012
+ if (output !== 'terminal')
1013
+ throw new CliUsageError('--json may be supplied only once.');
1014
+ output = 'json';
1015
+ }
1016
+ else if (argument === '--quiet')
1017
+ quiet = true;
1018
+ else if (argument === '--no-color')
1019
+ color = false;
1020
+ else if (argument.startsWith('-'))
1021
+ throw new CliUsageError(`Unknown option "${argument}".`);
1022
+ else if (source === null)
1023
+ source = argument;
1024
+ else
1025
+ throw new CliUsageError('assurance accepts one optional versioned JSON input file.');
1026
+ }
1027
+ if (quiet && output !== 'terminal')
1028
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1029
+ return { action: 'assurance', source, output, quiet, color };
1030
+ }
1031
+ function parseSharingReviewArguments(argv) {
1032
+ let source = null;
1033
+ let output = 'terminal';
1034
+ let marking = null;
1035
+ let recipientScope = null;
1036
+ let purpose = null;
1037
+ let humanReviewed = false;
1038
+ let personalDataReviewed = false;
1039
+ let redactionsConfirmed = false;
1040
+ let quiet = false;
1041
+ let color = true;
1042
+ for (let index = 0; index < argv.length; index++) {
1043
+ const argument = argv[index];
1044
+ if (argument === undefined)
1045
+ break;
1046
+ if (argument === '--json') {
1047
+ if (output !== 'terminal')
1048
+ throw new CliUsageError('--json may be supplied only once.');
1049
+ output = 'json';
1050
+ }
1051
+ else if (argument === '--marking') {
1052
+ if (marking !== null)
1053
+ throw new CliUsageError('--marking may be supplied only once.');
1054
+ const value = argv[++index];
1055
+ if (!['clear', 'green', 'amber', 'amber-strict', 'red'].includes(value || '')) {
1056
+ throw new CliUsageError('--marking requires clear, green, amber, amber-strict, or red.');
1057
+ }
1058
+ marking = value;
1059
+ }
1060
+ else if (argument === '--recipient-scope') {
1061
+ if (recipientScope !== null)
1062
+ throw new CliUsageError('--recipient-scope may be supplied only once.');
1063
+ const value = argv[++index];
1064
+ if (!['public', 'community', 'organization', 'named-recipients'].includes(value || '')) {
1065
+ throw new CliUsageError('--recipient-scope requires public, community, organization, or named-recipients.');
1066
+ }
1067
+ recipientScope = value;
1068
+ }
1069
+ else if (argument === '--purpose') {
1070
+ if (purpose !== null)
1071
+ throw new CliUsageError('--purpose may be supplied only once.');
1072
+ const value = argv[++index];
1073
+ if (!value || value.startsWith('-'))
1074
+ throw new CliUsageError('--purpose requires one bounded description.');
1075
+ const normalized = value.replace(/\s+/gu, ' ').trim();
1076
+ if (!normalized || normalized.length > 200)
1077
+ throw new CliUsageError('--purpose is limited to 200 characters.');
1078
+ purpose = normalized;
1079
+ }
1080
+ else if (argument === '--human-reviewed')
1081
+ humanReviewed = true;
1082
+ else if (argument === '--personal-data-reviewed')
1083
+ personalDataReviewed = true;
1084
+ else if (argument === '--redactions-confirmed')
1085
+ redactionsConfirmed = true;
1086
+ else if (argument === '--quiet')
1087
+ quiet = true;
1088
+ else if (argument === '--no-color')
1089
+ color = false;
1090
+ else if (argument.startsWith('-'))
1091
+ throw new CliUsageError(`Unknown option "${argument}".`);
1092
+ else if (source === null)
1093
+ source = argument;
1094
+ else
1095
+ throw new CliUsageError('sharing-review accepts one optional artefact JSON file.');
1096
+ }
1097
+ if (!marking || !recipientScope || !purpose) {
1098
+ throw new CliUsageError('sharing-review requires --marking, --recipient-scope, and --purpose.');
1099
+ }
1100
+ if (quiet && output !== 'terminal')
1101
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1102
+ return {
1103
+ action: 'sharing-review', source, output, marking, recipientScope, purpose,
1104
+ humanReviewed, personalDataReviewed, redactionsConfirmed, quiet, color,
1105
+ };
1106
+ }
958
1107
  function parseWorkflowPlanArguments(argv) {
959
1108
  const positional = [];
960
1109
  let output = 'terminal';
@@ -1018,6 +1167,36 @@ function parseDiffArguments(argv) {
1018
1167
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1019
1168
  return { action: 'diff', leftSource, rightSource, output, quiet, color };
1020
1169
  }
1170
+ function parseReconcileArguments(argv) {
1171
+ const sources = [];
1172
+ let output = 'terminal';
1173
+ let quiet = false;
1174
+ let color = true;
1175
+ for (const argument of argv) {
1176
+ if (argument === '--json') {
1177
+ if (output !== 'terminal')
1178
+ throw new CliUsageError('--json may be supplied only once.');
1179
+ output = 'json';
1180
+ }
1181
+ else if (argument === '--quiet')
1182
+ quiet = true;
1183
+ else if (argument === '--no-color')
1184
+ color = false;
1185
+ else if (argument.startsWith('-'))
1186
+ throw new CliUsageError(`Unknown option "${argument}".`);
1187
+ else
1188
+ sources.push(argument);
1189
+ }
1190
+ if (sources.length < 2 || sources.length > 5) {
1191
+ throw new CliUsageError('reconcile requires from 2 to 5 saved lookup JSON files.');
1192
+ }
1193
+ if (new Set(sources).size !== sources.length) {
1194
+ throw new CliUsageError('reconcile input files must be different.');
1195
+ }
1196
+ if (quiet && output !== 'terminal')
1197
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1198
+ return { action: 'reconcile', sources, output, quiet, color };
1199
+ }
1021
1200
  function parseTimelineArguments(argv) {
1022
1201
  const sources = [];
1023
1202
  let output = 'terminal';
@@ -1074,6 +1253,32 @@ function parseRegistrySupportArguments(argv) {
1074
1253
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1075
1254
  return { action: 'registry-support', target, output, quiet, color };
1076
1255
  }
1256
+ function parseRegistryDoctorArguments(argv) {
1257
+ let source = null;
1258
+ let output = 'terminal';
1259
+ let quiet = false;
1260
+ let color = true;
1261
+ for (const argument of argv) {
1262
+ if (argument === '--json') {
1263
+ if (output !== 'terminal')
1264
+ throw new CliUsageError('--json may be supplied only once.');
1265
+ output = 'json';
1266
+ }
1267
+ else if (argument === '--quiet')
1268
+ quiet = true;
1269
+ else if (argument === '--no-color')
1270
+ color = false;
1271
+ else if (argument.startsWith('-'))
1272
+ throw new CliUsageError(`Unknown option "${argument}".`);
1273
+ else if (source === null)
1274
+ source = argument;
1275
+ else
1276
+ throw new CliUsageError('registry-doctor accepts one optional saved Lookup JSON file.');
1277
+ }
1278
+ if (quiet && output !== 'terminal')
1279
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1280
+ return { action: 'registry-doctor', source, output, quiet, color };
1281
+ }
1077
1282
  function parseRiskCalibrateArguments(argv) {
1078
1283
  let source = null;
1079
1284
  let output = 'terminal';
@@ -1100,6 +1305,32 @@ function parseRiskCalibrateArguments(argv) {
1100
1305
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1101
1306
  return { action: 'risk-calibrate', source, output, quiet, color };
1102
1307
  }
1308
+ function parseLookalikeCalibrateArguments(argv) {
1309
+ let source = null;
1310
+ let output = 'terminal';
1311
+ let quiet = false;
1312
+ let color = true;
1313
+ for (const argument of argv) {
1314
+ if (argument === '--json') {
1315
+ if (output !== 'terminal')
1316
+ throw new CliUsageError('--json may be supplied only once.');
1317
+ output = 'json';
1318
+ }
1319
+ else if (argument === '--quiet')
1320
+ quiet = true;
1321
+ else if (argument === '--no-color')
1322
+ color = false;
1323
+ else if (argument.startsWith('-'))
1324
+ throw new CliUsageError(`Unknown option "${argument}".`);
1325
+ else if (source === null)
1326
+ source = argument;
1327
+ else
1328
+ throw new CliUsageError('lookalike-calibrate accepts one optional reviewed dataset JSON file.');
1329
+ }
1330
+ if (quiet && output !== 'terminal')
1331
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
1332
+ return { action: 'lookalike-calibrate', source, output, quiet, color };
1333
+ }
1103
1334
  function parseVerifyArtifactArguments(argv) {
1104
1335
  let source = null;
1105
1336
  let passphraseSource = null;
@@ -1133,7 +1364,7 @@ function parseVerifyArtifactArguments(argv) {
1133
1364
  else if (source === null)
1134
1365
  source = argument;
1135
1366
  else
1136
- throw new CliUsageError('verify-artifact accepts one optional JSON file. Otherwise pipe one artifact on stdin.');
1367
+ throw new CliUsageError('verify-artifact accepts one optional JSON file. Otherwise pipe one artefact on stdin.');
1137
1368
  }
1138
1369
  if (quiet && output !== 'terminal')
1139
1370
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
@@ -9,10 +9,18 @@ import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src
9
9
  import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
10
10
  import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
11
11
  import { DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
12
+ import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, verifyInvestigationCapsule, } from "../frontend/src/lib/analysis/investigation-capsule.js";
12
13
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
13
14
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
14
15
  export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
15
16
  export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
17
+ export class UnsupportedOfflineArtifactError extends TypeError {
18
+ code = 'unsupported_artifact';
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'UnsupportedOfflineArtifactError';
22
+ }
23
+ }
16
24
  const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
17
25
  [ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([ACQUISITION_DECISION_PACKET_VERSION]),
18
26
  [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
@@ -27,7 +35,7 @@ function record(value) {
27
35
  }
28
36
  function parseJson(raw) {
29
37
  if (typeof raw !== 'string')
30
- throw new TypeError('Artifact input must be UTF-8 JSON text.');
38
+ throw new TypeError('Artefact input must be UTF-8 JSON text.');
31
39
  const bytes = Buffer.byteLength(raw, 'utf8');
32
40
  if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
33
41
  throw new TypeError(`Artifact input must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
@@ -37,18 +45,22 @@ function parseJson(raw) {
37
45
  parsed = JSON.parse(raw);
38
46
  }
39
47
  catch {
40
- throw new TypeError('Artifact input is not valid JSON.');
48
+ throw new TypeError('Artefact input is not valid JSON.');
41
49
  }
42
50
  const value = record(parsed);
43
51
  if (!value)
44
- throw new TypeError('Artifact input must contain one JSON object.');
52
+ throw new TypeError('Artefact input must contain one JSON object.');
45
53
  return value;
46
54
  }
47
55
  function artifactVersion(value) {
48
- if (!Number.isSafeInteger(value.version) || Number(value.version) < 1 || Number(value.version) > 1000) {
49
- throw new TypeError('Artifact version is missing or invalid.');
56
+ const declared = value.version ?? value.schemaVersion;
57
+ if (value.version !== undefined && value.schemaVersion !== undefined && value.version !== value.schemaVersion) {
58
+ throw new TypeError('Artefact version declarations do not agree.');
59
+ }
60
+ if (!Number.isSafeInteger(declared) || Number(declared) < 1 || Number(declared) > 1000) {
61
+ throw new TypeError('Artefact version is missing or invalid.');
50
62
  }
51
- return Number(value.version);
63
+ return Number(declared);
52
64
  }
53
65
  function inputBytes(raw) {
54
66
  return Buffer.byteLength(raw, 'utf8');
@@ -83,18 +95,18 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
83
95
  async function verifySignedArtifact(raw, value, schema, version) {
84
96
  const supported = SIGNED_ARTIFACT_VERSIONS[schema];
85
97
  if (!supported?.has(version)) {
86
- throw new TypeError('This signed review-artifact schema or version is not supported.');
98
+ throw new UnsupportedOfflineArtifactError('This signed review-artifact schema or version is not supported.');
87
99
  }
88
100
  const integrity = record(value.integrity);
89
101
  if (!integrity
90
102
  || integrity.algorithm !== 'SHA-256'
91
103
  || typeof integrity.digestSha256 !== 'string'
92
104
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
93
- throw new TypeError('The signed review artifact has a missing or malformed integrity envelope.');
105
+ throw new TypeError('The signed review artefact has a missing or malformed integrity envelope.');
94
106
  }
95
107
  const { integrity: _integrity, ...unsigned } = value;
96
108
  if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
97
- throw new TypeError('The signed review artifact failed its SHA-256 integrity check.');
109
+ throw new TypeError('The signed review artefact failed its SHA-256 integrity check.');
98
110
  }
99
111
  if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA)
100
112
  verifyDomainControlManifest(value);
@@ -116,7 +128,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
116
128
  ciphertextBytes: null,
117
129
  }),
118
130
  limitations: Object.freeze([
119
- 'Digest verification detects changes to this exported artifact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
131
+ 'Digest verification detects changes to this exported artefact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
120
132
  ]),
121
133
  });
122
134
  }
@@ -163,7 +175,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
163
175
  }
164
176
  if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
165
177
  if (version !== CASE_RESPONSE_PACKET_VERSION) {
166
- throw new TypeError('This case-response packet version is not supported.');
178
+ throw new UnsupportedOfflineArtifactError('This case-response packet version is not supported.');
167
179
  }
168
180
  const integrity = record(value.integrity);
169
181
  if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
@@ -191,11 +203,41 @@ export async function verifyOfflineArtifact(raw, options = {}) {
191
203
  ]),
192
204
  });
193
205
  }
206
+ if (schema === INVESTIGATION_CAPSULE_SCHEMA) {
207
+ if (version !== INVESTIGATION_CAPSULE_VERSION) {
208
+ throw new UnsupportedOfflineArtifactError('This investigation-capsule version is not supported.');
209
+ }
210
+ const verification = await verifyInvestigationCapsule(value);
211
+ if (!verification.valid)
212
+ throw new TypeError('The investigation capsule failed its embedded projection integrity checks.');
213
+ return Object.freeze({
214
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
215
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
216
+ artifact: Object.freeze({ kind: 'investigation_capsule', schema, version }),
217
+ state: 'verified',
218
+ valid: true,
219
+ checks: Object.freeze({
220
+ structure: 'verified',
221
+ contentIntegrity: 'verified',
222
+ authenticatedEncryption: 'not_applicable',
223
+ }),
224
+ summary: Object.freeze({
225
+ inputBytes: inputBytes(raw),
226
+ sectionCount: null,
227
+ recordCount: null,
228
+ ciphertextBytes: null,
229
+ }),
230
+ limitations: Object.freeze([
231
+ 'The embedded brief, graph, and optional analyst-record projections match their declared digests; the linked Lookup evidence is not embedded and must be verified separately.',
232
+ 'Digest verification detects changed content but does not authenticate the analyst, signer, collection source, or truth of retained observations and assertions.',
233
+ ]),
234
+ });
235
+ }
194
236
  if (schema === SAVED_LOOKUP_SCHEMA) {
195
237
  if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
196
- throw new TypeError('This saved Lookup document version is not supported.');
238
+ throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
197
239
  }
198
- const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
240
+ const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artefact' });
199
241
  return Object.freeze({
200
242
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
201
243
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -222,7 +264,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
222
264
  }
223
265
  export function formatOfflineArtifactVerification(report) {
224
266
  const lines = [
225
- 'WHOISleuth offline artifact verification',
267
+ 'WHOISleuth offline artefact verification',
226
268
  `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
227
269
  `State: ${report.state}`,
228
270
  `Structure: ${report.checks.structure}`,
package/cli/compare.mjs CHANGED
@@ -150,7 +150,7 @@ function projectCliRegistrarPublicationInput(document) {
150
150
  }
151
151
  const parsed = objectOrNull(registrar?.parsed);
152
152
  if (status === 'success' && !parsed) {
153
- throw new CliUsageError('Successful registrar RDAP input is missing normalized parsed data.');
153
+ throw new CliUsageError('Successful registrar RDAP input is missing normalised parsed data.');
154
154
  }
155
155
  const projected = projectRdapSource(parsed, 'rdap.registrarRdap.parsed');
156
156
  delete projected.handle;