@slicedearth/whoisleuth-cli 1.39.1 → 1.43.0
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 +15 -0
- package/bin/whoisleuth.mjs +13 -2
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/completion.mjs +33 -10
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/runner.mjs +368 -306
- package/docs/cli-reference.md +240 -14
- package/docs/cli.md +42 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive.js +2 -0
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/risk-scoring.mjs +42 -1
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +24 -10
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
package/cli/artifact-verify.mjs
CHANGED
|
@@ -9,7 +9,10 @@ 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 { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION, } from "../lib/domain-change-packet.mjs";
|
|
12
13
|
import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, verifyInvestigationCapsule, } from "../frontend/src/lib/analysis/investigation-capsule.js";
|
|
14
|
+
import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, } from "./investigation-manifest.mjs";
|
|
15
|
+
import { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, verifyCliCasePack, } from "./case-pack.mjs";
|
|
13
16
|
export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
|
|
14
17
|
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
|
|
15
18
|
export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
@@ -27,6 +30,8 @@ const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
|
|
|
27
30
|
[BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
|
|
28
31
|
[BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
|
|
29
32
|
[DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_MANIFEST_VERSION]),
|
|
33
|
+
[DOMAIN_CHANGE_PACKET_SCHEMA]: new Set([DOMAIN_CHANGE_PACKET_VERSION]),
|
|
34
|
+
[INVESTIGATION_MANIFEST_SCHEMA]: new Set([INVESTIGATION_MANIFEST_VERSION]),
|
|
30
35
|
});
|
|
31
36
|
function record(value) {
|
|
32
37
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -72,7 +77,7 @@ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
|
|
|
72
77
|
artifact: Object.freeze({
|
|
73
78
|
kind: encrypted ? 'encrypted_workspace_archive' : 'workspace_archive',
|
|
74
79
|
schema: WORKSPACE_ARCHIVE_SCHEMA,
|
|
75
|
-
version: archive.
|
|
80
|
+
version: archive.sourceVersion,
|
|
76
81
|
}),
|
|
77
82
|
state: 'verified',
|
|
78
83
|
valid: true,
|
|
@@ -134,6 +139,35 @@ async function verifySignedArtifact(raw, value, schema, version) {
|
|
|
134
139
|
}
|
|
135
140
|
export async function verifyOfflineArtifact(raw, options = {}) {
|
|
136
141
|
const value = parseJson(raw);
|
|
142
|
+
const casePackPacket = record(value.packet);
|
|
143
|
+
if (casePackPacket?.schema === CLI_CASE_PACK_SCHEMA) {
|
|
144
|
+
const verified = verifyCliCasePack(value);
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
|
|
147
|
+
version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
148
|
+
artifact: Object.freeze({
|
|
149
|
+
kind: 'cli_case_pack',
|
|
150
|
+
schema: CLI_CASE_PACK_SCHEMA,
|
|
151
|
+
version: CLI_CASE_PACK_VERSION,
|
|
152
|
+
}),
|
|
153
|
+
state: 'verified',
|
|
154
|
+
valid: true,
|
|
155
|
+
checks: Object.freeze({
|
|
156
|
+
structure: 'verified',
|
|
157
|
+
contentIntegrity: 'verified',
|
|
158
|
+
authenticatedEncryption: 'not_applicable',
|
|
159
|
+
}),
|
|
160
|
+
summary: Object.freeze({
|
|
161
|
+
inputBytes: inputBytes(raw),
|
|
162
|
+
sectionCount: null,
|
|
163
|
+
recordCount: verified.caseCount,
|
|
164
|
+
ciphertextBytes: null,
|
|
165
|
+
}),
|
|
166
|
+
limitations: Object.freeze([
|
|
167
|
+
'Case-pack verification detects changes after export and checks the bounded case collection; it does not authenticate the analyst, review decision, source observations, or recipient authorisation.',
|
|
168
|
+
]),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
137
171
|
if (isEncryptedWorkspaceArchive(value)) {
|
|
138
172
|
const inspected = inspectEncryptedWorkspaceArchive(value);
|
|
139
173
|
if (!options.passphrase) {
|
package/cli/bulk-checkpoint.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { open } from 'node:fs/promises';
|
|
3
|
+
import { recordOrNull } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
4
|
import { CliUsageError } from "./errors.mjs";
|
|
4
5
|
import { writePrivateFile } from "./output-file.mjs";
|
|
5
6
|
export const CLI_BULK_CHECKPOINT_SCHEMA = 'whoisleuth.cli.bulk-checkpoint';
|
|
@@ -10,11 +11,6 @@ const MAX_CHECKPOINT_OBJECT_KEYS = 256;
|
|
|
10
11
|
const MAX_CHECKPOINT_ARRAY_ITEMS = 1_024;
|
|
11
12
|
const MAX_CHECKPOINT_STRING_LENGTH = 32_768;
|
|
12
13
|
const COMPACT_RESULT_KEYS = new Set(['availability', 'diagnostics']);
|
|
13
|
-
function record(value) {
|
|
14
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
-
? value
|
|
16
|
-
: null;
|
|
17
|
-
}
|
|
18
14
|
function checkpointDigest(queries, deep) {
|
|
19
15
|
return createHash('sha256')
|
|
20
16
|
.update(deep ? 'deep\n' : 'fast\n')
|
|
@@ -37,7 +33,7 @@ function isBoundedCheckpointJson(value, depth = 0) {
|
|
|
37
33
|
return value.length <= MAX_CHECKPOINT_ARRAY_ITEMS
|
|
38
34
|
&& value.every((item) => isBoundedCheckpointJson(item, depth + 1));
|
|
39
35
|
}
|
|
40
|
-
const object =
|
|
36
|
+
const object = recordOrNull(value);
|
|
41
37
|
if (!object)
|
|
42
38
|
return false;
|
|
43
39
|
const entries = Object.entries(object);
|
|
@@ -45,18 +41,18 @@ function isBoundedCheckpointJson(value, depth = 0) {
|
|
|
45
41
|
&& entries.every(([key, item]) => key.length <= 128 && isBoundedCheckpointJson(item, depth + 1));
|
|
46
42
|
}
|
|
47
43
|
function normalizeCompactResult(value) {
|
|
48
|
-
const result =
|
|
44
|
+
const result = recordOrNull(value);
|
|
49
45
|
if (!result || !isBoundedCheckpointJson(result))
|
|
50
46
|
return null;
|
|
51
47
|
const keys = Object.keys(result);
|
|
52
48
|
if (keys.length !== COMPACT_RESULT_KEYS.size || keys.some((key) => !COMPACT_RESULT_KEYS.has(key)))
|
|
53
49
|
return null;
|
|
54
|
-
if (!
|
|
50
|
+
if (!recordOrNull(result.availability) || !recordOrNull(result.diagnostics))
|
|
55
51
|
return null;
|
|
56
52
|
return result;
|
|
57
53
|
}
|
|
58
54
|
function normalizeCheckpointResult(value, queries, classifyQuery) {
|
|
59
|
-
const item =
|
|
55
|
+
const item = recordOrNull(value);
|
|
60
56
|
const index = item?.index;
|
|
61
57
|
if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= queries.length)
|
|
62
58
|
return null;
|
|
@@ -72,7 +68,7 @@ function normalizeCheckpointResult(value, queries, classifyQuery) {
|
|
|
72
68
|
if (item.ok !== true || !result)
|
|
73
69
|
return null;
|
|
74
70
|
const classified = classifyQuery(query);
|
|
75
|
-
const storedClassified =
|
|
71
|
+
const storedClassified = recordOrNull(item.classified);
|
|
76
72
|
if (storedClassified?.type !== classified.type || storedClassified.value !== classified.value)
|
|
77
73
|
return null;
|
|
78
74
|
return { index: Number(index), query, ok: true, classified, result };
|
|
@@ -88,7 +84,7 @@ function parseBulkCheckpoint(text, options) {
|
|
|
88
84
|
catch {
|
|
89
85
|
throw new CliUsageError('Bulk checkpoint must be valid JSON.');
|
|
90
86
|
}
|
|
91
|
-
const document =
|
|
87
|
+
const document = recordOrNull(parsed);
|
|
92
88
|
const expectedDigest = checkpointDigest(options.queries, options.deep);
|
|
93
89
|
if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || document.version !== CLI_BULK_CHECKPOINT_VERSION) {
|
|
94
90
|
throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version ${CLI_BULK_CHECKPOINT_VERSION}.`);
|
|
@@ -8,6 +8,9 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
|
8
8
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
9
9
|
import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
|
|
10
10
|
import { formatTerminalBulk } from "./formatters/terminal.mjs";
|
|
11
|
+
import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
|
|
12
|
+
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
13
|
+
import { formatCliJunit } from "./ci-report.mjs";
|
|
11
14
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
12
15
|
async function runBulkCommand(args, dependencies, context) {
|
|
13
16
|
const eventProgress = createCliProgressEvents(context.stderr, {
|
|
@@ -31,6 +34,14 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
31
34
|
throw new CliUsageError(`Could not read bulk input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
32
35
|
}
|
|
33
36
|
const parsed = parseBulkQueries(input, { deep: args.deep });
|
|
37
|
+
if (args.plan) {
|
|
38
|
+
const document = buildCollectionPreflight({
|
|
39
|
+
command: 'bulk', targetCount: parsed.queries.length, targetLimit: args.deep ? 50 : 500,
|
|
40
|
+
deep: args.deep, concurrency: args.concurrency, output: args.output, checkpoint: false,
|
|
41
|
+
});
|
|
42
|
+
context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
|
|
43
|
+
return EXIT_CODES.SUCCESS;
|
|
44
|
+
}
|
|
34
45
|
const classify = dependencies.classifyQuery || classifyQuery;
|
|
35
46
|
const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
|
|
36
47
|
const checkpoint = args.checkpoint
|
|
@@ -82,9 +93,12 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
82
93
|
filter: args.filter,
|
|
83
94
|
};
|
|
84
95
|
const selectedItems = selectBulkItems(items, args.filter);
|
|
96
|
+
const selectedDocument = buildCliBulkDocument(selectedItems, metadata);
|
|
85
97
|
if (!args.quiet) {
|
|
86
98
|
if (args.output === 'json')
|
|
87
|
-
context.writeStdout(formatJsonDocument(
|
|
99
|
+
context.writeStdout(formatJsonDocument(selectedDocument));
|
|
100
|
+
else if (args.output === 'junit')
|
|
101
|
+
context.writeStdout(formatCliJunit(selectedDocument));
|
|
88
102
|
else if (args.output === 'jsonl')
|
|
89
103
|
context.writeStdout(formatJsonLines(selectedItems, metadata));
|
|
90
104
|
else if (args.output === 'csv')
|
|
@@ -102,7 +116,10 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
102
116
|
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
|
|
103
117
|
}
|
|
104
118
|
}
|
|
105
|
-
const
|
|
119
|
+
const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || []);
|
|
120
|
+
if (policyFindings.length && !eventProgress.enabled)
|
|
121
|
+
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
122
|
+
const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
|
|
106
123
|
? EXIT_CODES.PARTIAL_FAILURE
|
|
107
124
|
: EXIT_CODES.SUCCESS;
|
|
108
125
|
eventProgress.emit({ event: 'completed', exitCode });
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { buildCaseReport } from "../frontend/src/lib/analysis/case-report.js";
|
|
5
|
+
import { CASE_IMPORT_VERSIONS, MAX_CASE_IMPORT_BYTES, CASE_SCHEMA_VERSION, normalizeCaseStore, } from "../frontend/src/lib/analysis/case-model.js";
|
|
6
|
+
import { CliUsageError } from "./errors.mjs";
|
|
7
|
+
export const CLI_CASE_PACK_SCHEMA = 'whoisleuth.cli.case-pack';
|
|
8
|
+
export const CLI_CASE_PACK_VERSION = 1;
|
|
9
|
+
export const MAX_CASE_PACK_INPUT_BYTES = 4 * 1024 * 1024;
|
|
10
|
+
export const MAX_CASE_PACK_CASES = 25;
|
|
11
|
+
function redactedCase(record, audience) {
|
|
12
|
+
if (audience === 'internal')
|
|
13
|
+
return structuredClone(record);
|
|
14
|
+
return {
|
|
15
|
+
...structuredClone(record),
|
|
16
|
+
notes: [],
|
|
17
|
+
actions: audience === 'public' ? [] : record.actions.map((item) => ({ ...item, recipient: '[redacted]' })),
|
|
18
|
+
manualTrail: record.manualTrail.map((item) => ({ ...item, target: null })),
|
|
19
|
+
assertions: audience === 'public' ? [] : structuredClone(record.assertions),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function buildCliCasePack(input, options, generatedAt = new Date().toISOString()) {
|
|
23
|
+
if (!options.reviewed)
|
|
24
|
+
throw new CliUsageError('case-pack requires --reviewed after the audience-specific output has been checked.');
|
|
25
|
+
if (Buffer.byteLength(input, 'utf8') > MAX_CASE_PACK_INPUT_BYTES)
|
|
26
|
+
throw new CliUsageError('Case-pack input is limited to 4 MiB.');
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new CliUsageError('Case-pack input must be valid JSON.');
|
|
33
|
+
}
|
|
34
|
+
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
35
|
+
if (typeof root.version !== 'number' || !CASE_IMPORT_VERSIONS.includes(root.version) || !Array.isArray(root.cases)) {
|
|
36
|
+
throw new CliUsageError(`Case-pack input must be a supported WHOISleuth case export through schema ${CASE_SCHEMA_VERSION}.`);
|
|
37
|
+
}
|
|
38
|
+
const normalised = normalizeCaseStore(root).cases;
|
|
39
|
+
if (!normalised.length)
|
|
40
|
+
throw new CliUsageError('Case-pack input did not contain a valid case.');
|
|
41
|
+
if (normalised.length !== root.cases.length) {
|
|
42
|
+
throw new CliUsageError('Case-pack input contains an invalid or duplicate case. Correct the browser export before packaging it.');
|
|
43
|
+
}
|
|
44
|
+
if (normalised.length > MAX_CASE_PACK_CASES) {
|
|
45
|
+
throw new CliUsageError(`Case packs are limited to ${MAX_CASE_PACK_CASES} reviewed cases. Export a smaller selected set so no case is silently omitted.`);
|
|
46
|
+
}
|
|
47
|
+
const cases = normalised.map((item) => redactedCase(item, options.audience));
|
|
48
|
+
const reports = cases.map((item) => buildCaseReport(item, { includeNotes: false, generatedAt }).json);
|
|
49
|
+
const exclusions = options.audience === 'internal'
|
|
50
|
+
? ['Raw upstream payloads and credentials are outside the case schema.']
|
|
51
|
+
: options.audience === 'trusted'
|
|
52
|
+
? ['Case notes', 'Recipient values', 'Manual trail targets', 'Raw upstream payloads and credentials']
|
|
53
|
+
: ['Case notes', 'Actions and recipient values', 'Analyst assertions', 'Manual trail targets', 'Raw upstream payloads and credentials'];
|
|
54
|
+
const packet = Object.freeze({
|
|
55
|
+
schema: CLI_CASE_PACK_SCHEMA,
|
|
56
|
+
version: CLI_CASE_PACK_VERSION,
|
|
57
|
+
audience: options.audience,
|
|
58
|
+
reviewed: true,
|
|
59
|
+
reports: Object.freeze(reports),
|
|
60
|
+
redactionManifest: Object.freeze({ excluded: Object.freeze(exclusions), sourceCaseCount: normalised.length }),
|
|
61
|
+
limitations: Object.freeze([
|
|
62
|
+
'This local package is browser-importable through its top-level case collection and does not upload or submit evidence.',
|
|
63
|
+
'The reviewed flag records a deliberate CLI choice; it does not prove recipient authorisation, factual correctness, or legal sufficiency.',
|
|
64
|
+
'Importing the package does not restore fields excluded by its audience profile.',
|
|
65
|
+
]),
|
|
66
|
+
});
|
|
67
|
+
const unsigned = { version: CASE_SCHEMA_VERSION, exportedAt: generatedAt, cases, packet };
|
|
68
|
+
const document = Object.freeze({
|
|
69
|
+
...unsigned,
|
|
70
|
+
integrity: Object.freeze({
|
|
71
|
+
algorithm: 'SHA-256',
|
|
72
|
+
canonicalization: 'sorted-json-v1',
|
|
73
|
+
digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJson(unsigned)).digest('hex')}`,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
77
|
+
throw new CliUsageError('The generated case pack exceeds the browser 2 MiB import limit. Select fewer cases or a more restrictive audience so no evidence is silently omitted.');
|
|
78
|
+
}
|
|
79
|
+
return document;
|
|
80
|
+
}
|
|
81
|
+
export function verifyCliCasePack(input) {
|
|
82
|
+
const root = input && typeof input === 'object' && !Array.isArray(input)
|
|
83
|
+
? input
|
|
84
|
+
: null;
|
|
85
|
+
const packet = root?.packet && typeof root.packet === 'object' && !Array.isArray(root.packet)
|
|
86
|
+
? root.packet
|
|
87
|
+
: null;
|
|
88
|
+
const integrity = root?.integrity && typeof root.integrity === 'object' && !Array.isArray(root.integrity)
|
|
89
|
+
? root.integrity
|
|
90
|
+
: null;
|
|
91
|
+
if (!root
|
|
92
|
+
|| !packet
|
|
93
|
+
|| packet.schema !== CLI_CASE_PACK_SCHEMA
|
|
94
|
+
|| packet.version !== CLI_CASE_PACK_VERSION
|
|
95
|
+
|| packet.reviewed !== true
|
|
96
|
+
|| !['internal', 'public', 'trusted'].includes(String(packet.audience))
|
|
97
|
+
|| !Array.isArray(packet.reports)
|
|
98
|
+
|| !Array.isArray(root.cases)
|
|
99
|
+
|| root.cases.length < 1
|
|
100
|
+
|| root.cases.length > MAX_CASE_PACK_CASES
|
|
101
|
+
|| packet.reports.length !== root.cases.length
|
|
102
|
+
|| typeof root.version !== 'number'
|
|
103
|
+
|| !CASE_IMPORT_VERSIONS.includes(root.version)
|
|
104
|
+
|| !integrity
|
|
105
|
+
|| integrity.algorithm !== 'SHA-256'
|
|
106
|
+
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
107
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
108
|
+
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
109
|
+
throw new TypeError('The CLI case pack structure or integrity envelope is invalid.');
|
|
110
|
+
}
|
|
111
|
+
if (Buffer.byteLength(JSON.stringify(root), 'utf8') > MAX_CASE_IMPORT_BYTES) {
|
|
112
|
+
throw new TypeError('The CLI case pack exceeds the browser import limit.');
|
|
113
|
+
}
|
|
114
|
+
const { integrity: _integrity, ...unsigned } = root;
|
|
115
|
+
const calculated = `sha256:${createHash('sha256').update(canonicalArtifactJson(unsigned)).digest('hex')}`;
|
|
116
|
+
if (calculated !== integrity.digestSha256) {
|
|
117
|
+
throw new TypeError('The CLI case pack failed its SHA-256 integrity check.');
|
|
118
|
+
}
|
|
119
|
+
const normalised = normalizeCaseStore(root).cases;
|
|
120
|
+
if (normalised.length !== root.cases.length) {
|
|
121
|
+
throw new TypeError('The CLI case pack contains an invalid case collection.');
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({ caseCount: normalised.length });
|
|
124
|
+
}
|
|
125
|
+
export function formatCliCasePack(document) {
|
|
126
|
+
return [
|
|
127
|
+
'Reviewed case pack',
|
|
128
|
+
`Audience ${document.packet.audience}`,
|
|
129
|
+
`Cases ${document.cases.length}`,
|
|
130
|
+
`Reports ${document.packet.reports.length}`,
|
|
131
|
+
`Digest ${document.integrity.digestSha256}`,
|
|
132
|
+
'',
|
|
133
|
+
...document.packet.redactionManifest.excluded.map((item) => `Excluded: ${item}`),
|
|
134
|
+
'',
|
|
135
|
+
].join('\n');
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { WHOISLEUTH_REQUEST_POLICY_URL } from "../lib/project-metadata.mjs";
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
4
|
+
}
|
|
5
|
+
function xml(value) {
|
|
6
|
+
return String(value).replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"').replace(/'/gu, ''');
|
|
7
|
+
}
|
|
8
|
+
function junitCases(document) {
|
|
9
|
+
const schema = String(document.schema || '');
|
|
10
|
+
if (schema === 'whoisleuth.cli.lookup') {
|
|
11
|
+
const diagnostics = record(document.diagnostics);
|
|
12
|
+
const cases = Object.entries(diagnostics).slice(0, 64).map(([source, value]) => {
|
|
13
|
+
const state = String(record(value).status ?? record(value).state ?? 'unavailable').toLowerCase();
|
|
14
|
+
return ['error', 'failed', 'partial', 'rate_limited', 'timeout', 'unavailable'].includes(state)
|
|
15
|
+
? { name: `source ${source}`, failure: `Source state: ${state}` }
|
|
16
|
+
: { name: `source ${source}` };
|
|
17
|
+
});
|
|
18
|
+
return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
|
|
19
|
+
}
|
|
20
|
+
if (schema === 'whoisleuth.cli.bulk') {
|
|
21
|
+
const summary = record(document.summary);
|
|
22
|
+
const failed = Number(summary.failed ?? 0);
|
|
23
|
+
const results = Array.isArray(document.results) ? document.results.map(record) : [];
|
|
24
|
+
const inconclusive = results.filter((item) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(String(record(item.availability).state || '').toLowerCase())).length;
|
|
25
|
+
return [
|
|
26
|
+
failed ? { name: 'collection failures', failure: `${failed} target collection(s) failed.` } : { name: 'collection failures' },
|
|
27
|
+
inconclusive ? { name: 'authority conclusions', failure: `${inconclusive} target state(s) remained inconclusive.` } : { name: 'authority conclusions' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
if (schema === 'whoisleuth.cli.domain-control-monitor') {
|
|
31
|
+
const collection = record(document.collection);
|
|
32
|
+
const summary = record(record(document.flightRecorder).summary);
|
|
33
|
+
const failed = Number(collection.failed ?? 0);
|
|
34
|
+
const drift = Number(summary.unexpectedChanges ?? 0);
|
|
35
|
+
return [
|
|
36
|
+
failed ? { name: 'domain control collection', failure: `${failed} owned-domain collection(s) failed.` } : { name: 'domain control collection' },
|
|
37
|
+
drift ? { name: 'unexpected domain control changes', failure: `${drift} complete observation change(s) require review.` } : { name: 'unexpected domain control changes' },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
return [{ name: 'WHOISleuth result', failure: 'Unsupported document for JUnit conversion.' }];
|
|
41
|
+
}
|
|
42
|
+
export function formatCliJunit(document) {
|
|
43
|
+
const cases = junitCases(record(document));
|
|
44
|
+
const failures = cases.filter((item) => item.failure).length;
|
|
45
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="WHOISleuth" tests="${cases.length}" failures="${failures}">\n${cases.map((item) => ` <testcase classname="whoisleuth" name="${xml(item.name)}">${item.failure ? `<failure message="${xml(item.failure)}" />` : ''}</testcase>`).join('\n')}\n</testsuite>\n`;
|
|
46
|
+
}
|
|
47
|
+
export function buildPostureSarif(document) {
|
|
48
|
+
const root = record(document);
|
|
49
|
+
const checks = Array.isArray(root.checks) ? root.checks.map(record).slice(0, 64) : [];
|
|
50
|
+
const actionable = checks.filter((item) => ['warning', 'danger'].includes(String(item.status).toLowerCase()));
|
|
51
|
+
const rules = actionable.map((item) => ({
|
|
52
|
+
id: `WHOISLEUTH-${String(item.id || 'posture').replace(/[^a-z0-9_-]/giu, '-').slice(0, 64)}`,
|
|
53
|
+
shortDescription: { text: String(item.label || 'Domain posture finding').slice(0, 200) },
|
|
54
|
+
help: { text: String(item.remediation || item.detail || 'Review this owned-domain posture finding.').slice(0, 1000) },
|
|
55
|
+
}));
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
version: '2.1.0',
|
|
58
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
59
|
+
runs: [Object.freeze({
|
|
60
|
+
tool: { driver: { name: 'WHOISleuth', informationUri: WHOISLEUTH_REQUEST_POLICY_URL, rules } },
|
|
61
|
+
results: actionable.map((item) => ({
|
|
62
|
+
ruleId: `WHOISLEUTH-${String(item.id || 'posture').replace(/[^a-z0-9_-]/giu, '-').slice(0, 64)}`,
|
|
63
|
+
level: String(item.status).toLowerCase() === 'danger' ? 'error' : 'warning',
|
|
64
|
+
message: { text: `${String(item.summary || item.label || 'Posture finding').slice(0, 500)}${item.remediation ? ` Next: ${String(item.remediation).slice(0, 500)}` : ''}` },
|
|
65
|
+
})),
|
|
66
|
+
properties: {
|
|
67
|
+
ownedDomainConfirmed: true,
|
|
68
|
+
generatedAt: typeof root.generatedAt === 'string' ? root.generatedAt : null,
|
|
69
|
+
evidenceBoundary: 'Passive owned-domain DNS posture; raw records and contact data omitted from SARIF.',
|
|
70
|
+
},
|
|
71
|
+
})],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const CLI_COLLECTION_PREFLIGHT_SCHEMA = 'whoisleuth.cli.collection-preflight';
|
|
2
|
+
export const CLI_COLLECTION_PREFLIGHT_VERSION = 1;
|
|
3
|
+
export function buildCollectionPreflight(options) {
|
|
4
|
+
const sourceFamilies = options.deep
|
|
5
|
+
? ['RDAP', 'WHOIS', 'DNS', 'HTTP and static page identity', 'TLS', 'network allocation and routing context', 'configured optional threat sources']
|
|
6
|
+
: ['RDAP', 'conditional DNS authority evidence'];
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
schema: CLI_COLLECTION_PREFLIGHT_SCHEMA,
|
|
9
|
+
version: CLI_COLLECTION_PREFLIGHT_VERSION,
|
|
10
|
+
command: options.command,
|
|
11
|
+
mode: options.deep ? 'deep' : 'fast',
|
|
12
|
+
networkRequestsMade: false,
|
|
13
|
+
scope: Object.freeze({
|
|
14
|
+
selectedTargets: options.targetCount,
|
|
15
|
+
commandTargetLimit: options.targetLimit,
|
|
16
|
+
concurrency: options.concurrency,
|
|
17
|
+
sourceFamilies: Object.freeze(sourceFamilies),
|
|
18
|
+
exactRequestCountKnown: false,
|
|
19
|
+
requestBoundary: 'Collector-specific timeouts, response limits, referrals, redirects, retries, registry policy and provider quotas remain enforced independently.',
|
|
20
|
+
}),
|
|
21
|
+
disclosure: Object.freeze([
|
|
22
|
+
'Targets are sent only to the source families required by the selected mode and permitted by registry/provider policy.',
|
|
23
|
+
...(options.customResolvers ? ['Selected public DNS resolvers receive the DNS questions needed for collection.'] : ['The runtime DNS resolver receives DNS questions when DNS collection is required.']),
|
|
24
|
+
...(options.deep ? ['Target web infrastructure receives bounded homepage, favicon and TLS requests; configured optional providers can receive the documented target representation.'] : []),
|
|
25
|
+
]),
|
|
26
|
+
persistence: Object.freeze({
|
|
27
|
+
output: options.output,
|
|
28
|
+
checkpoint: options.checkpoint,
|
|
29
|
+
note: options.checkpoint
|
|
30
|
+
? 'A private resumable checkpoint is written locally; final output remains separate.'
|
|
31
|
+
: 'No checkpoint is written. Final output is emitted only to the selected destination.',
|
|
32
|
+
}),
|
|
33
|
+
review: Object.freeze({
|
|
34
|
+
allowlistAffectsPriorityOnly: options.allowlist === true,
|
|
35
|
+
enforcementActions: false,
|
|
36
|
+
}),
|
|
37
|
+
limitations: Object.freeze([
|
|
38
|
+
'This offline preflight reports collection families and hard target/concurrency bounds, not an exact raw request count.',
|
|
39
|
+
'Redirects, WHOIS referrals, cache state, feature configuration, policy decisions, source availability and prerequisite evidence can change the requests actually made.',
|
|
40
|
+
'A planned source may still return unsupported, unavailable, partial, rate-limited or inconclusive evidence.',
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function formatCollectionPreflight(document) {
|
|
45
|
+
return [
|
|
46
|
+
`WHOISleuth ${document.command} preflight`,
|
|
47
|
+
`Mode ${document.mode}`,
|
|
48
|
+
`Selected targets ${document.scope.selectedTargets} / ${document.scope.commandTargetLimit}`,
|
|
49
|
+
`Concurrency ${document.scope.concurrency}`,
|
|
50
|
+
'Network requests none',
|
|
51
|
+
'',
|
|
52
|
+
'Collector families',
|
|
53
|
+
...document.scope.sourceFamilies.map((source) => ` ${source}`),
|
|
54
|
+
'',
|
|
55
|
+
...document.disclosure.map((item) => `Disclosure: ${item}`),
|
|
56
|
+
...document.limitations.map((item) => `Limitation: ${item}`),
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|