@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.
- package/README.md +17 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +236 -5
- package/cli/artifact-verify.mjs +56 -14
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +18 -3
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-plan.mjs +2 -2
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/offline-evidence-review.mjs +20 -2
- package/cli/registry-doctor.mjs +125 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +236 -17
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +1 -1
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +1 -1
- package/package.json +5 -1
- 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
|
-
|
|
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
|
|
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
|
|
package/bin/whoisleuth.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
process.exit(130);
|
|
19
|
+
process.stdout.write(`${metadata.version}\n`);
|
|
21
20
|
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.');
|
package/cli/artifact-verify.mjs
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
52
|
+
throw new TypeError('Artefact input must contain one JSON object.');
|
|
45
53
|
return value;
|
|
46
54
|
}
|
|
47
55
|
function artifactVersion(value) {
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
238
|
+
throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
|
|
197
239
|
}
|
|
198
|
-
const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup
|
|
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
|
|
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
|
|
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;
|