@slicedearth/whoisleuth-cli 1.34.0 → 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 +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- 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/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- 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 +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- 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 -847
- 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-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- 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/http-summary.js +2 -1
- 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/utils.js +7 -40
- 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/website-snapshot-model.js +57 -3
- 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 +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- 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/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
package/cli/artifact-verify.mjs
CHANGED
|
@@ -8,15 +8,25 @@ import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedW
|
|
|
8
8
|
import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
|
|
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
|
+
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";
|
|
11
13
|
export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
|
|
12
14
|
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
|
|
13
15
|
export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
14
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
|
+
}
|
|
15
24
|
const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
|
|
16
25
|
[ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([ACQUISITION_DECISION_PACKET_VERSION]),
|
|
17
26
|
[BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
|
|
18
27
|
[BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
|
|
19
28
|
[BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
|
|
29
|
+
[DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_MANIFEST_VERSION]),
|
|
20
30
|
});
|
|
21
31
|
function record(value) {
|
|
22
32
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -25,7 +35,7 @@ function record(value) {
|
|
|
25
35
|
}
|
|
26
36
|
function parseJson(raw) {
|
|
27
37
|
if (typeof raw !== 'string')
|
|
28
|
-
throw new TypeError('
|
|
38
|
+
throw new TypeError('Artefact input must be UTF-8 JSON text.');
|
|
29
39
|
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
30
40
|
if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
|
|
31
41
|
throw new TypeError(`Artifact input must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
|
|
@@ -35,18 +45,22 @@ function parseJson(raw) {
|
|
|
35
45
|
parsed = JSON.parse(raw);
|
|
36
46
|
}
|
|
37
47
|
catch {
|
|
38
|
-
throw new TypeError('
|
|
48
|
+
throw new TypeError('Artefact input is not valid JSON.');
|
|
39
49
|
}
|
|
40
50
|
const value = record(parsed);
|
|
41
51
|
if (!value)
|
|
42
|
-
throw new TypeError('
|
|
52
|
+
throw new TypeError('Artefact input must contain one JSON object.');
|
|
43
53
|
return value;
|
|
44
54
|
}
|
|
45
55
|
function artifactVersion(value) {
|
|
46
|
-
|
|
47
|
-
|
|
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.');
|
|
48
62
|
}
|
|
49
|
-
return Number(
|
|
63
|
+
return Number(declared);
|
|
50
64
|
}
|
|
51
65
|
function inputBytes(raw) {
|
|
52
66
|
return Buffer.byteLength(raw, 'utf8');
|
|
@@ -81,19 +95,21 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
|
|
|
81
95
|
async function verifySignedArtifact(raw, value, schema, version) {
|
|
82
96
|
const supported = SIGNED_ARTIFACT_VERSIONS[schema];
|
|
83
97
|
if (!supported?.has(version)) {
|
|
84
|
-
throw new
|
|
98
|
+
throw new UnsupportedOfflineArtifactError('This signed review-artifact schema or version is not supported.');
|
|
85
99
|
}
|
|
86
100
|
const integrity = record(value.integrity);
|
|
87
101
|
if (!integrity
|
|
88
102
|
|| integrity.algorithm !== 'SHA-256'
|
|
89
103
|
|| typeof integrity.digestSha256 !== 'string'
|
|
90
104
|
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
91
|
-
throw new TypeError('The signed review
|
|
105
|
+
throw new TypeError('The signed review artefact has a missing or malformed integrity envelope.');
|
|
92
106
|
}
|
|
93
107
|
const { integrity: _integrity, ...unsigned } = value;
|
|
94
108
|
if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
|
|
95
|
-
throw new TypeError('The signed review
|
|
109
|
+
throw new TypeError('The signed review artefact failed its SHA-256 integrity check.');
|
|
96
110
|
}
|
|
111
|
+
if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA)
|
|
112
|
+
verifyDomainControlManifest(value);
|
|
97
113
|
return Object.freeze({
|
|
98
114
|
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
|
|
99
115
|
version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
@@ -112,7 +128,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
|
|
|
112
128
|
ciphertextBytes: null,
|
|
113
129
|
}),
|
|
114
130
|
limitations: Object.freeze([
|
|
115
|
-
'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.',
|
|
116
132
|
]),
|
|
117
133
|
});
|
|
118
134
|
}
|
|
@@ -159,7 +175,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
159
175
|
}
|
|
160
176
|
if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
|
|
161
177
|
if (version !== CASE_RESPONSE_PACKET_VERSION) {
|
|
162
|
-
throw new
|
|
178
|
+
throw new UnsupportedOfflineArtifactError('This case-response packet version is not supported.');
|
|
163
179
|
}
|
|
164
180
|
const integrity = record(value.integrity);
|
|
165
181
|
if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
|
|
@@ -187,11 +203,41 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
187
203
|
]),
|
|
188
204
|
});
|
|
189
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
|
+
}
|
|
190
236
|
if (schema === SAVED_LOOKUP_SCHEMA) {
|
|
191
237
|
if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
|
|
192
|
-
throw new
|
|
238
|
+
throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
|
|
193
239
|
}
|
|
194
|
-
const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup
|
|
240
|
+
const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artefact' });
|
|
195
241
|
return Object.freeze({
|
|
196
242
|
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
|
|
197
243
|
version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
@@ -218,7 +264,7 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
218
264
|
}
|
|
219
265
|
export function formatOfflineArtifactVerification(report) {
|
|
220
266
|
const lines = [
|
|
221
|
-
'WHOISleuth offline
|
|
267
|
+
'WHOISleuth offline artefact verification',
|
|
222
268
|
`Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
|
|
223
269
|
`State: ${report.state}`,
|
|
224
270
|
`Structure: ${report.checks.structure}`,
|
package/cli/bulk.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { abortable } from "../lib/abort.mjs";
|
|
3
3
|
import { CliUsageError } from "./arguments.mjs";
|
|
4
|
+
import { MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, } from "../lib/bulk-limits.mjs";
|
|
4
5
|
const MAX_BULK_INPUT_BYTES = 1024 * 1024;
|
|
5
|
-
const MAX_FAST_BULK_QUERIES = 500;
|
|
6
|
-
const MAX_DEEP_BULK_QUERIES = 50;
|
|
7
6
|
async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES) {
|
|
8
7
|
if (!stream || stream.isTTY)
|
|
9
8
|
return '';
|
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;
|
package/cli/completion.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { CLI_COMMANDS } from "./arguments.mjs";
|
|
2
|
+
import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
|
|
2
3
|
const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
|
|
3
4
|
const OPTIONS_BY_COMMAND = Object.freeze({
|
|
4
|
-
lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
|
|
5
|
+
lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
|
|
5
6
|
bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
|
|
6
7
|
'ct-search': ['--json', '--quiet', '--no-color'],
|
|
7
8
|
discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
|
|
@@ -10,7 +11,9 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
10
11
|
http: ['--json', '--quiet', '--no-color'],
|
|
11
12
|
tls: ['--json', '--quiet', '--no-color'],
|
|
12
13
|
'registry-support': ['--json', '--quiet', '--no-color'],
|
|
14
|
+
'registry-doctor': ['--json', '--quiet', '--no-color'],
|
|
13
15
|
'risk-calibrate': ['--json', '--quiet', '--no-color'],
|
|
16
|
+
'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
|
|
14
17
|
'verify-artifact': ['--passphrase-file', '--json', '--quiet', '--no-color'],
|
|
15
18
|
'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--json', '--quiet', '--no-color'],
|
|
16
19
|
'sign-artifact': ['--private-key-file'],
|
|
@@ -19,7 +22,13 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
19
22
|
compare: ['--json', '--quiet', '--no-color'],
|
|
20
23
|
'page-compare': ['--json', '--quiet', '--no-color'],
|
|
21
24
|
'mail-review': ['--json', '--quiet', '--no-color'],
|
|
25
|
+
'review-evidence': ['--mmdb', '--json', '--quiet', '--no-color'],
|
|
26
|
+
'domain-control': ['--json', '--quiet', '--no-color'],
|
|
27
|
+
assurance: ['--json', '--quiet', '--no-color'],
|
|
28
|
+
'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
|
|
29
|
+
'workflow-plan': ['--json', '--quiet', '--no-color'],
|
|
22
30
|
diff: ['--json', '--quiet', '--no-color'],
|
|
31
|
+
reconcile: ['--json', '--quiet', '--no-color'],
|
|
23
32
|
timeline: ['--json', '--quiet', '--no-color'],
|
|
24
33
|
export: ['--markdown', '--html', '--compact'],
|
|
25
34
|
completion: [],
|
|
@@ -37,16 +46,24 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
37
46
|
http: 'Inspect one homepage request',
|
|
38
47
|
tls: 'Inspect one TLS connection',
|
|
39
48
|
'registry-support': 'Explain local registry coverage',
|
|
49
|
+
'registry-doctor': 'Diagnose saved registry collection',
|
|
40
50
|
'risk-calibrate': 'Replay reviewed Risk labels offline',
|
|
51
|
+
'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
|
|
41
52
|
'verify-artifact': 'Validate saved evidence offline',
|
|
42
53
|
'inspect-archive': 'Inspect an archive locally',
|
|
43
|
-
'sign-artifact': 'Sign a reviewed
|
|
54
|
+
'sign-artifact': 'Sign a reviewed artefact locally',
|
|
44
55
|
'verify-signature': 'Verify a signed evidence package',
|
|
45
56
|
'source-report': 'Build a target-free source report',
|
|
46
57
|
compare: 'Compare registry publications in one lookup',
|
|
47
58
|
'page-compare': 'Compare saved static page evidence',
|
|
48
59
|
'mail-review': 'Review saved passive mail evidence',
|
|
60
|
+
'review-evidence': 'Review supplied evidence offline',
|
|
61
|
+
'domain-control': 'Build or review a domain control manifest',
|
|
62
|
+
assurance: 'Review domain change, recovery, or retirement plans',
|
|
63
|
+
'sharing-review': 'Lint an artefact before deliberate sharing',
|
|
64
|
+
'workflow-plan': 'Plan a fixed investigation recipe',
|
|
49
65
|
diff: 'Compare two saved domain lookups',
|
|
66
|
+
reconcile: 'Reconcile independently labelled observations',
|
|
50
67
|
timeline: 'Build same-domain history from saved lookups',
|
|
51
68
|
export: 'Convert a lookup to an evidence report',
|
|
52
69
|
completion: 'Print shell completion',
|
|
@@ -58,6 +75,8 @@ const VALUE_OPTIONS = Object.freeze({
|
|
|
58
75
|
'--preset': ['common', 'impersonation', 'all'],
|
|
59
76
|
'--keyboard': ['qwerty', 'azerty', 'qwertz', 'all'],
|
|
60
77
|
'--mail-profile': ['standard', 'defensive-no-mail', 'parked'],
|
|
78
|
+
'--marking': ['clear', 'green', 'amber', 'amber-strict', 'red'],
|
|
79
|
+
'--recipient-scope': ['public', 'community', 'organization', 'named-recipients'],
|
|
61
80
|
'--concurrency': ['1', '2', '3', '4', '5', '6', '7', '8'],
|
|
62
81
|
});
|
|
63
82
|
const FILE_OPTIONS = Object.freeze([
|
|
@@ -74,10 +93,13 @@ const FILE_OPTIONS = Object.freeze([
|
|
|
74
93
|
const TEXT_OPTIONS = Object.freeze([
|
|
75
94
|
'--families',
|
|
76
95
|
'--resolver',
|
|
96
|
+
'--purpose',
|
|
97
|
+
'--observer',
|
|
77
98
|
'--retired-selectors',
|
|
78
99
|
'--search',
|
|
79
100
|
'--selectors',
|
|
80
101
|
'--tlds',
|
|
102
|
+
'--vantage',
|
|
81
103
|
]);
|
|
82
104
|
function commandOptions(command) {
|
|
83
105
|
return [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
|
|
@@ -99,6 +121,10 @@ _whoisleuth_completion() {
|
|
|
99
121
|
COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
|
|
100
122
|
return
|
|
101
123
|
fi
|
|
124
|
+
if [[ "\${command}" == "workflow-plan" && \${COMP_CWORD} -eq 2 ]]; then
|
|
125
|
+
COMPREPLY=( $(compgen -W "${INVESTIGATION_PLAN_RECIPES.join(' ')}" -- "\${current}") )
|
|
126
|
+
return
|
|
127
|
+
fi
|
|
102
128
|
case "\${previous}" in
|
|
103
129
|
${valueCases}
|
|
104
130
|
${FILE_OPTIONS.join('|')}) COMPREPLY=( $(compgen -f -- "\${current}") ); return ;;
|
|
@@ -132,6 +158,10 @@ _whoisleuth() {
|
|
|
132
158
|
compadd -- bash zsh fish powershell
|
|
133
159
|
return
|
|
134
160
|
fi
|
|
161
|
+
if [[ "\${command}" == "workflow-plan" && CURRENT -eq 3 ]]; then
|
|
162
|
+
compadd -- ${INVESTIGATION_PLAN_RECIPES.join(' ')}
|
|
163
|
+
return
|
|
164
|
+
fi
|
|
135
165
|
case "\${previous}" in
|
|
136
166
|
${valueCases}
|
|
137
167
|
${FILE_OPTIONS.join('|')}) _files; return ;;
|
|
@@ -165,6 +195,7 @@ complete -c whoisleuth -n '__fish_use_subcommand' -l help
|
|
|
165
195
|
complete -c whoisleuth -n '__fish_use_subcommand' -l version
|
|
166
196
|
${commandLines.join('\n')}
|
|
167
197
|
complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
|
|
198
|
+
complete -c whoisleuth -n '__fish_seen_subcommand_from workflow-plan' -a '${INVESTIGATION_PLAN_RECIPES.join(' ')}'
|
|
168
199
|
${optionLines.join('\n')}
|
|
169
200
|
${valueLines.join('\n')}
|
|
170
201
|
`;
|
|
@@ -184,6 +215,7 @@ ${Object.entries(commandOptions).map(([command, options]) => ` '${command}' =
|
|
|
184
215
|
$values = @{
|
|
185
216
|
${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
|
|
186
217
|
'completion' = @('bash', 'zsh', 'fish', 'powershell')
|
|
218
|
+
'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
|
|
187
219
|
}
|
|
188
220
|
$elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
|
|
189
221
|
$command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
|
|
@@ -192,6 +224,8 @@ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @($
|
|
|
192
224
|
@($commands) + @('--help', '--version')
|
|
193
225
|
} elseif ($command -eq 'completion' -and $elements.Count -le 3) {
|
|
194
226
|
$values['completion']
|
|
227
|
+
} elseif ($command -eq 'workflow-plan' -and $elements.Count -le 3) {
|
|
228
|
+
$values['workflow-plan']
|
|
195
229
|
} elseif ($values.ContainsKey($previous)) {
|
|
196
230
|
$values[$previous]
|
|
197
231
|
} elseif ($options.ContainsKey($command)) {
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
|
|
2
1
|
import { withTimeout } from "../lib/abort.mjs";
|
|
3
|
-
import { whoisQuery } from "../lib/whois-transport.mjs";
|
|
4
2
|
const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
|
|
5
3
|
const DOCTOR_VERSION = 1;
|
|
6
4
|
const MINIMUM_NODE_MAJOR = 24;
|
|
@@ -62,9 +60,17 @@ async function buildDoctorReport(options) {
|
|
|
62
60
|
}));
|
|
63
61
|
}
|
|
64
62
|
else {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
const safeFetchModule = options.resolveAddresses && options.fetchHttps
|
|
64
|
+
? null
|
|
65
|
+
: await import("../lib/safe-fetch.mjs");
|
|
66
|
+
const whoisModule = options.queryWhois
|
|
67
|
+
? null
|
|
68
|
+
: await import("../lib/whois-transport.mjs");
|
|
69
|
+
const resolve = options.resolveAddresses || safeFetchModule?.resolvePublicAddresses;
|
|
70
|
+
const fetchHttps = options.fetchHttps || safeFetchModule?.safeFetch;
|
|
71
|
+
const query = options.queryWhois || whoisModule?.whoisQuery;
|
|
72
|
+
if (!resolve || !fetchHttps || !query)
|
|
73
|
+
throw new TypeError('Network diagnostic dependencies are unavailable.');
|
|
68
74
|
const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
|
|
69
75
|
&& Number(options.networkTimeoutMs) >= 1
|
|
70
76
|
&& Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
|
|
@@ -92,7 +92,7 @@ export function parseSignArtifactArguments(argv) {
|
|
|
92
92
|
else if (source === null)
|
|
93
93
|
source = argument;
|
|
94
94
|
else
|
|
95
|
-
throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one
|
|
95
|
+
throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one artefact on stdin.');
|
|
96
96
|
}
|
|
97
97
|
if (!privateKeySource)
|
|
98
98
|
throw new CliUsageError('sign-artifact requires --private-key-file.');
|
|
@@ -80,9 +80,9 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
80
80
|
return EXIT_CODES.SUCCESS;
|
|
81
81
|
}
|
|
82
82
|
if (args.action === 'sign-artifact') {
|
|
83
|
-
const input = await readArtifact(args.source, 'Evidence
|
|
83
|
+
const input = await readArtifact(args.source, 'Evidence artefact input', dependencies);
|
|
84
84
|
if (!input.trim()) {
|
|
85
|
-
throw new CliUsageError('sign-artifact requires one reviewed
|
|
85
|
+
throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
|
|
86
86
|
}
|
|
87
87
|
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
|
|
88
88
|
const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
|
package/cli/evidence-signing.mjs
CHANGED
|
@@ -100,10 +100,10 @@ function payloadBytes(payload) {
|
|
|
100
100
|
return Buffer.from(canonicalArtifactJson(payload), 'utf8');
|
|
101
101
|
}
|
|
102
102
|
async function signableArtifact(raw) {
|
|
103
|
-
const artifact = parseObject(raw, 'Evidence
|
|
103
|
+
const artifact = parseObject(raw, 'Evidence artefact');
|
|
104
104
|
const verification = await verifyOfflineArtifact(raw);
|
|
105
|
-
if (!['case_response_packet', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
|
-
throw new TypeError('Only reviewed response packets and supported review manifests can be signed.');
|
|
105
|
+
if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
|
+
throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
|
|
107
107
|
}
|
|
108
108
|
return { artifact, verification };
|
|
109
109
|
}
|
|
@@ -193,7 +193,7 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
193
193
|
trustedPublicKeyPem
|
|
194
194
|
? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
|
|
195
195
|
: 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
|
|
196
|
-
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the
|
|
196
|
+
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
|
|
197
197
|
'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
|
|
198
198
|
'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
|
|
199
199
|
]),
|
package/cli/export-evidence.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { projectCliLookupComparisonInput } from "./compare.mjs";
|
|
2
3
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const { version: APPLICATION_VERSION } = require('../package.json');
|
|
3
6
|
function objectOrNull(value) {
|
|
4
7
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
8
|
}
|
|
@@ -14,7 +17,11 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
14
17
|
throw new TypeError('Lookup evidence export dependency is required.');
|
|
15
18
|
}
|
|
16
19
|
const buildLookupEvidence = dependency.buildLookupEvidence;
|
|
17
|
-
const result = objectOrNull(buildLookupEvidence(source, {
|
|
20
|
+
const result = objectOrNull(buildLookupEvidence(source, {
|
|
21
|
+
generatedAt,
|
|
22
|
+
idnAnalysis: null,
|
|
23
|
+
applicationVersion: APPLICATION_VERSION,
|
|
24
|
+
}));
|
|
18
25
|
if (!result
|
|
19
26
|
|| result.schema !== dependency.LOOKUP_EVIDENCE_SCHEMA
|
|
20
27
|
|| result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
@@ -25,4 +32,4 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
25
32
|
function formatCliEvidenceExport(document, compact = false) {
|
|
26
33
|
return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
|
|
27
34
|
}
|
|
28
|
-
export { buildCliEvidenceExport, formatCliEvidenceExport };
|
|
35
|
+
export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -12,7 +12,7 @@ function renderGroups(groups) {
|
|
|
12
12
|
}
|
|
13
13
|
function renderComparison(comparison) {
|
|
14
14
|
if (!comparison.fields.length)
|
|
15
|
-
return '<p class="empty">No comparable
|
|
15
|
+
return '<p class="empty">No comparable normalised fields were published.</p>';
|
|
16
16
|
const rows = comparison.fields.map((field) => `<tr><th scope="row">${escapeHtml(field.label)}</th><td><span class="status">${escapeHtml(field.status)}</span></td><td>${escapeHtml(field.rdap)}</td><td>${escapeHtml(field.whois)}</td></tr>`).join('');
|
|
17
17
|
const omitted = comparison.omitted
|
|
18
18
|
? `<p class="omission">${escapeHtml(comparison.omitted)} additional comparison fields omitted.</p>`
|
package/cli/formatters/json.mjs
CHANGED
|
@@ -17,7 +17,13 @@ const CLI_POSTURE_SCHEMA_VERSION = 1;
|
|
|
17
17
|
const CLI_HTTP_SCHEMA_VERSION = 1;
|
|
18
18
|
const CLI_TLS_SCHEMA_VERSION = 1;
|
|
19
19
|
const CLI_COMPARE_SCHEMA_VERSION = 3;
|
|
20
|
-
function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast') {
|
|
20
|
+
function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast', collectionContext = {}) {
|
|
21
|
+
const observerLabel = typeof collectionContext.observerLabel === 'string'
|
|
22
|
+
? collectionContext.observerLabel
|
|
23
|
+
: null;
|
|
24
|
+
const vantageLabel = typeof collectionContext.vantageLabel === 'string'
|
|
25
|
+
? collectionContext.vantageLabel
|
|
26
|
+
: null;
|
|
21
27
|
return {
|
|
22
28
|
schema: CLI_LOOKUP_SCHEMA,
|
|
23
29
|
version: CLI_LOOKUP_SCHEMA_VERSION,
|
|
@@ -28,6 +34,12 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
|
|
|
28
34
|
inputHostname: classified.inputHostname,
|
|
29
35
|
registrableDomain: classified.registrableDomain,
|
|
30
36
|
isSubdomain: classified.isSubdomain,
|
|
37
|
+
...((observerLabel || vantageLabel) ? {
|
|
38
|
+
collectionContext: {
|
|
39
|
+
...(observerLabel ? { observerLabel } : {}),
|
|
40
|
+
...(vantageLabel ? { vantageLabel } : {}),
|
|
41
|
+
},
|
|
42
|
+
} : {}),
|
|
31
43
|
...result,
|
|
32
44
|
};
|
|
33
45
|
}
|
|
@@ -271,7 +271,6 @@ function formatTerminalRegistrySupport(document) {
|
|
|
271
271
|
`WHOIS scope ${supportLabel(whois.queryScope)}`,
|
|
272
272
|
`WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
|
|
273
273
|
`WHOIS parser ${supportLabel(whois.parserProfile)}`,
|
|
274
|
-
`Fallback ${profile.fallbackProfile ? supportLabel(profile.fallbackProfile) : 'None'}`,
|
|
275
274
|
`Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
|
|
276
275
|
];
|
|
277
276
|
for (const file of files)
|
|
@@ -302,6 +301,11 @@ function formatTerminalBulk(items, metadata) {
|
|
|
302
301
|
}
|
|
303
302
|
function formatTerminalCtSearch(document) {
|
|
304
303
|
const matches = Array.isArray(document.matches) ? document.matches : [];
|
|
304
|
+
const certificateGroups = Array.isArray(document.certificateGroups) ? document.certificateGroups : [];
|
|
305
|
+
const crossDomainGroups = certificateGroups.filter((value) => {
|
|
306
|
+
const group = terminalRecord(value);
|
|
307
|
+
return Array.isArray(group.domains) && group.domains.length > 1;
|
|
308
|
+
});
|
|
305
309
|
const visible = matches.slice(0, MAX_CT_TERMINAL_MATCHES);
|
|
306
310
|
const observation = terminalRecord(document.observation);
|
|
307
311
|
const lines = [
|
|
@@ -310,7 +314,9 @@ function formatTerminalCtSearch(document) {
|
|
|
310
314
|
`Certificates ${safeTerminalValue(document.certCount, '0')}`,
|
|
311
315
|
`Observed hosts ${safeTerminalValue(Array.isArray(document.domains) ? document.domains.length : 0, '0')}`,
|
|
312
316
|
`Matches ${safeTerminalValue(matches.length, '0')}`,
|
|
317
|
+
`Issuance groups ${safeTerminalValue(certificateGroups.length, '0')} (${safeTerminalValue(crossDomainGroups.length, '0')} cross-domain)`,
|
|
313
318
|
`Truncated ${document.truncated ? 'Yes' : 'No'}`,
|
|
319
|
+
`Group cap ${document.certificateGroupsTruncated ? 'Reached' : 'Not reached'}`,
|
|
314
320
|
'',
|
|
315
321
|
];
|
|
316
322
|
if (!visible.length) {
|
|
@@ -587,7 +593,7 @@ function formatTerminalCompare(document) {
|
|
|
587
593
|
}
|
|
588
594
|
lines.push('');
|
|
589
595
|
if (!fields.length) {
|
|
590
|
-
lines.push('Neither source published a comparable
|
|
596
|
+
lines.push('Neither source published a comparable normalised field.');
|
|
591
597
|
}
|
|
592
598
|
else {
|
|
593
599
|
for (const value of fields) {
|
|
@@ -607,7 +613,7 @@ function formatTerminalCompare(document) {
|
|
|
607
613
|
const publicationDifferences = publicationFields.length - (Number(publicationCounts.equivalent) || 0);
|
|
608
614
|
lines.push('', 'Registry / registrar RDAP publication', `Registry RDAP ${comparisonStatusLabel(registryHealth.status)}`, `Registrar RDAP ${comparisonStatusLabel(registrarHealth.status)}`, `Compared ${safeTerminalValue(publicationFields.length, '0')} field${publicationFields.length === 1 ? '' : 's'}`, `Equivalent ${safeTerminalValue(publicationCounts.equivalent, '0')}`, `Differences ${safeTerminalValue(publicationDifferences, '0')}`, '');
|
|
609
615
|
if (!publicationFields.length) {
|
|
610
|
-
lines.push('Neither RDAP publication exposed a comparable
|
|
616
|
+
lines.push('Neither RDAP publication exposed a comparable normalised field.');
|
|
611
617
|
}
|
|
612
618
|
else {
|
|
613
619
|
for (const value of publicationFields) {
|
|
@@ -629,6 +635,7 @@ function formatTerminalRiskCalibration(document) {
|
|
|
629
635
|
const bands = terminalRecord(summary.scoreBands);
|
|
630
636
|
const thresholds = Array.isArray(document.thresholds) ? document.thresholds : [];
|
|
631
637
|
const records = Array.isArray(document.records) ? document.records : [];
|
|
638
|
+
const comparison = terminalRecord(document.modelComparison);
|
|
632
639
|
const visible = records.slice(0, MAX_RISK_CALIBRATION_TERMINAL_RECORDS);
|
|
633
640
|
const lines = [
|
|
634
641
|
`Risk model v${safeTerminalValue(document.riskModelVersion)}`,
|
|
@@ -642,7 +649,10 @@ function formatTerminalRiskCalibration(document) {
|
|
|
642
649
|
];
|
|
643
650
|
for (const value of thresholds) {
|
|
644
651
|
const threshold = terminalRecord(value);
|
|
645
|
-
lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)}`);
|
|
652
|
+
lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)} F1 ${safeTerminalValue(threshold.f1)}`);
|
|
653
|
+
}
|
|
654
|
+
if (comparison.available === true) {
|
|
655
|
+
lines.push('', `Model comparison v${safeTerminalValue(comparison.previousModelVersion)} → v${safeTerminalValue(comparison.currentModelVersion)} · ${safeTerminalValue(comparison.scoresChanged, '0')} score changes · ${safeTerminalValue(comparison.thresholdClassificationsChanged, '0')} review-band changes`);
|
|
646
656
|
}
|
|
647
657
|
lines.push('', 'Records');
|
|
648
658
|
for (const value of visible) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { domainToASCII } from 'node:url';
|
|
2
|
+
export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
|
|
3
|
+
export const CLI_INVESTIGATION_PLAN_VERSION = 1;
|
|
4
|
+
export const INVESTIGATION_PLAN_RECIPES = [
|
|
5
|
+
'domain-triage',
|
|
6
|
+
'lookalike-review',
|
|
7
|
+
'owned-domain-review',
|
|
8
|
+
'historical-comparison',
|
|
9
|
+
];
|
|
10
|
+
const RECIPES = Object.freeze({
|
|
11
|
+
'domain-triage': Object.freeze({
|
|
12
|
+
label: 'New domain triage',
|
|
13
|
+
requiresDomain: true,
|
|
14
|
+
objective: 'Collect and preserve a source-aware registration, DNS, HTTP, TLS, page, and network-context review.',
|
|
15
|
+
steps: (domain) => Object.freeze([
|
|
16
|
+
step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
|
|
17
|
+
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
|
|
18
|
+
step('verify', 'Verify the exported artefact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
|
|
19
|
+
]),
|
|
20
|
+
}),
|
|
21
|
+
'lookalike-review': Object.freeze({
|
|
22
|
+
label: 'Lookalike candidate review',
|
|
23
|
+
requiresDomain: false,
|
|
24
|
+
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
25
|
+
steps: (subject) => Object.freeze([
|
|
26
|
+
step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
|
|
27
|
+
step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
|
|
28
|
+
step('inspect', 'Deep-review one selected candidate', 'lookup', ['<selected-domain>', '--deep', '--json'], 'network', 'analyst_selection', 'whoisleuth.cli.lookup', 'Select a candidate deliberately; generation does not prove registration, control, intent, or maliciousness.'),
|
|
29
|
+
]),
|
|
30
|
+
}),
|
|
31
|
+
'owned-domain-review': Object.freeze({
|
|
32
|
+
label: 'Owned domain posture review',
|
|
33
|
+
requiresDomain: true,
|
|
34
|
+
objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
|
|
35
|
+
steps: (domain) => Object.freeze([
|
|
36
|
+
step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
|
|
37
|
+
step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
|
|
38
|
+
step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only complete supplied observations may produce drift.'),
|
|
39
|
+
]),
|
|
40
|
+
}),
|
|
41
|
+
'historical-comparison': Object.freeze({
|
|
42
|
+
label: 'Historical observation comparison',
|
|
43
|
+
requiresDomain: true,
|
|
44
|
+
objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
|
|
45
|
+
steps: (domain) => Object.freeze([
|
|
46
|
+
step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
|
|
47
|
+
step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
|
|
48
|
+
step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
|
|
49
|
+
]),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
function step(id, label, command, args, mode, approval, produces, completion) {
|
|
53
|
+
return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
|
|
54
|
+
}
|
|
55
|
+
function boundedSubject(value) {
|
|
56
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
57
|
+
throw new TypeError('Investigation-plan subject must be bounded text without control characters.');
|
|
58
|
+
}
|
|
59
|
+
return value.replace(/\s+/gu, ' ').trim();
|
|
60
|
+
}
|
|
61
|
+
function normalizedDomain(value) {
|
|
62
|
+
const candidate = domainToASCII(value.toLowerCase().replace(/\.$/u, ''));
|
|
63
|
+
if (!candidate || candidate.length > 253 || !candidate.includes('.'))
|
|
64
|
+
return null;
|
|
65
|
+
return candidate.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
|
|
66
|
+
? candidate
|
|
67
|
+
: null;
|
|
68
|
+
}
|
|
69
|
+
export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue = new Date().toISOString()) {
|
|
70
|
+
const recipe = RECIPES[recipeId];
|
|
71
|
+
if (!recipe)
|
|
72
|
+
throw new TypeError('Investigation-plan recipe is unsupported.');
|
|
73
|
+
const suppliedSubject = boundedSubject(subjectValue);
|
|
74
|
+
const subject = recipe.requiresDomain ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
|
|
75
|
+
if (!subject)
|
|
76
|
+
throw new TypeError('This investigation-plan recipe requires one valid domain.');
|
|
77
|
+
const parsedTime = Date.parse(generatedAtValue);
|
|
78
|
+
if (!Number.isFinite(parsedTime))
|
|
79
|
+
throw new TypeError('Investigation-plan generation time is invalid.');
|
|
80
|
+
const steps = recipe.steps(subject);
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
schema: CLI_INVESTIGATION_PLAN_SCHEMA,
|
|
83
|
+
version: CLI_INVESTIGATION_PLAN_VERSION,
|
|
84
|
+
generatedAt: new Date(parsedTime).toISOString(),
|
|
85
|
+
recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
|
|
86
|
+
subject,
|
|
87
|
+
execution: 'plan_only',
|
|
88
|
+
steps,
|
|
89
|
+
limitations: Object.freeze([
|
|
90
|
+
'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
|
|
91
|
+
'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
|
|
92
|
+
'Analyst-selection steps require reviewed local artefacts; placeholders are never interpreted as file paths by this planner.',
|
|
93
|
+
]),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function formatInvestigationPlan(plan) {
|
|
97
|
+
return [
|
|
98
|
+
`Investigation plan: ${plan.recipe.label}`,
|
|
99
|
+
`Subject ${plan.subject}`,
|
|
100
|
+
`Mode ${plan.execution}`,
|
|
101
|
+
'',
|
|
102
|
+
...plan.steps.flatMap((item, index) => [
|
|
103
|
+
`${index + 1}. ${item.label}`,
|
|
104
|
+
` ${item.command} ${item.arguments.join(' ')}`,
|
|
105
|
+
` ${item.mode}; approval: ${item.approval.replaceAll('_', ' ')}`,
|
|
106
|
+
` ${item.completion}`,
|
|
107
|
+
]),
|
|
108
|
+
'',
|
|
109
|
+
...plan.limitations.map((item) => `Limitation: ${item}`),
|
|
110
|
+
'',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|