@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 +23 -4
- package/bin/whoisleuth.mjs +31 -9
- 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 +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- 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 +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- 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/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -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/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- 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 +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- 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-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- 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/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -10,6 +10,8 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
|
10
10
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
11
11
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
12
12
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
13
|
+
import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
|
|
14
|
+
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
13
15
|
async function readAllowlist(source, dependencies, classify) {
|
|
14
16
|
if (!source)
|
|
15
17
|
return new Set();
|
|
@@ -40,6 +42,15 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
40
42
|
if (!candidates.length)
|
|
41
43
|
throw new CliUsageError('discover-scan did not generate any valid candidates to collect.');
|
|
42
44
|
const queries = candidates.map((candidate) => String(candidate.domain));
|
|
45
|
+
if (args.plan) {
|
|
46
|
+
const document = buildCollectionPreflight({
|
|
47
|
+
command: 'discover-scan', targetCount: queries.length, targetLimit: args.deep ? 50 : 500,
|
|
48
|
+
deep: args.deep, concurrency: args.concurrency, output: args.output, checkpoint: false,
|
|
49
|
+
customResolvers: Boolean(args.resolverText), allowlist: Boolean(args.allowlistSource),
|
|
50
|
+
});
|
|
51
|
+
context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
|
|
52
|
+
return EXIT_CODES.SUCCESS;
|
|
53
|
+
}
|
|
43
54
|
const classify = dependencies.classifyQuery || classifyQuery;
|
|
44
55
|
let resolverServers = [];
|
|
45
56
|
if (args.resolverText) {
|
|
@@ -64,6 +75,8 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
64
75
|
: null;
|
|
65
76
|
const indicator = context.beginProgress(`Collecting 0 of ${queries.length} generated candidates`);
|
|
66
77
|
let completed = checkpoint?.initialResults.length || 0;
|
|
78
|
+
const resumedItems = new Map((checkpoint?.initialResults ?? []).map((item) => [item.index, item]));
|
|
79
|
+
const settledItems = new Map();
|
|
67
80
|
if (completed)
|
|
68
81
|
indicator.update(`Resumed ${completed} of ${queries.length} generated candidates`);
|
|
69
82
|
let checkpointFailure = null;
|
|
@@ -79,12 +92,23 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
79
92
|
...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
|
|
80
93
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
81
94
|
onItemSettled: (item) => {
|
|
95
|
+
const observed = {
|
|
96
|
+
...item,
|
|
97
|
+
observedAt: context.now(),
|
|
98
|
+
collectionOrigin: 'current_run',
|
|
99
|
+
};
|
|
100
|
+
settledItems.set(item.index, observed);
|
|
82
101
|
completed += 1;
|
|
83
102
|
indicator.update(`Collected ${completed} of ${queries.length} generated candidates`);
|
|
84
103
|
eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
|
|
85
|
-
checkpoint?.record(
|
|
104
|
+
checkpoint?.record(observed);
|
|
86
105
|
},
|
|
87
106
|
});
|
|
107
|
+
items = items.map((item) => resumedItems.get(item.index) ?? settledItems.get(item.index) ?? {
|
|
108
|
+
...item,
|
|
109
|
+
observedAt: null,
|
|
110
|
+
collectionOrigin: 'current_run',
|
|
111
|
+
});
|
|
88
112
|
}
|
|
89
113
|
finally {
|
|
90
114
|
context.endProgress();
|
|
@@ -132,7 +156,10 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
132
156
|
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
|
-
const
|
|
159
|
+
const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
|
|
160
|
+
if (policyFindings.length && !eventProgress.enabled)
|
|
161
|
+
context.writeStderr(formatFailPolicyNotice(policyFindings));
|
|
162
|
+
const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
|
|
136
163
|
? EXIT_CODES.PARTIAL_FAILURE
|
|
137
164
|
: EXIT_CODES.SUCCESS;
|
|
138
165
|
eventProgress.emit({ event: 'completed', exitCode });
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { CliUsageError } from "./errors.mjs";
|
|
2
|
+
import { boundedCliInputError, CliUsageError } from "./errors.mjs";
|
|
4
3
|
import { writePrivateFile } from "./output-file.mjs";
|
|
5
4
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
5
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
6
6
|
export const CLI_DISCOVERY_SNAPSHOT_SCHEMA = 'whoisleuth.cli.discovery-snapshot';
|
|
7
7
|
export const CLI_DISCOVERY_SNAPSHOT_VERSION = 1;
|
|
8
8
|
export const MAX_DISCOVERY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
9
9
|
export const MAX_DISCOVERY_SNAPSHOT_DOMAINS = 10_000;
|
|
10
|
-
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
11
10
|
function configurationDigest(configuration) {
|
|
12
11
|
return createHash('sha256').update(JSON.stringify({
|
|
13
12
|
seed: configuration.seed,
|
|
@@ -69,22 +68,16 @@ function parseDiscoverySnapshot(text, expectedConfigurationDigest) {
|
|
|
69
68
|
}
|
|
70
69
|
async function readExistingSnapshot(path) {
|
|
71
70
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
return await handle.readFile({ encoding: 'utf8' });
|
|
79
|
-
}
|
|
80
|
-
finally {
|
|
81
|
-
await handle.close();
|
|
82
|
-
}
|
|
71
|
+
return await readBoundedRegularTextFile(path, {
|
|
72
|
+
maximumBytes: MAX_DISCOVERY_SNAPSHOT_BYTES,
|
|
73
|
+
label: 'Discovery snapshot input',
|
|
74
|
+
allowSymbolicLink: true,
|
|
75
|
+
});
|
|
83
76
|
}
|
|
84
77
|
catch (error) {
|
|
85
78
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
86
79
|
return null;
|
|
87
|
-
throw error;
|
|
80
|
+
throw boundedCliInputError(error, 'Discovery snapshot input');
|
|
88
81
|
}
|
|
89
82
|
}
|
|
90
83
|
async function updateDiscoverySnapshot(path, candidatesValue, configuration, generatedAt) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { classifyQuery } from "../lib/classify.mjs";
|
|
2
|
+
import { buildDomainControlFlightRecorder, } from "../lib/domain-control-flight-recorder.mjs";
|
|
3
|
+
import { verifyDomainControlManifest } from "../lib/domain-control-manifest.mjs";
|
|
4
|
+
import { buildCliLookupDocument } from "./formatters/json.mjs";
|
|
5
|
+
import { buildCliDomainControlReview, } from "./domain-control-observations.mjs";
|
|
6
|
+
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
7
|
+
export const CLI_DOMAIN_CONTROL_MONITOR_SCHEMA = 'whoisleuth.cli.domain-control-monitor';
|
|
8
|
+
export const CLI_DOMAIN_CONTROL_MONITOR_VERSION = 1;
|
|
9
|
+
export const MAX_DOMAIN_CONTROL_MONITOR_DOMAINS = 20;
|
|
10
|
+
function previousSnapshot(input) {
|
|
11
|
+
if (!input)
|
|
12
|
+
return { observations: [] };
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new CliUsageError('Previous monitor snapshot must be valid JSON.');
|
|
19
|
+
}
|
|
20
|
+
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
21
|
+
if (root.schema !== CLI_DOMAIN_CONTROL_MONITOR_SCHEMA || root.version !== CLI_DOMAIN_CONTROL_MONITOR_VERSION || !Array.isArray(root.observations)) {
|
|
22
|
+
throw new CliUsageError(`Previous monitor snapshot must use ${CLI_DOMAIN_CONTROL_MONITOR_SCHEMA} version ${CLI_DOMAIN_CONTROL_MONITOR_VERSION}.`);
|
|
23
|
+
}
|
|
24
|
+
const validated = buildDomainControlFlightRecorder({
|
|
25
|
+
schema: 'whoisleuth.domain-control-flight-recorder.input', version: 1, observations: root.observations, approvedWindows: [],
|
|
26
|
+
});
|
|
27
|
+
void validated;
|
|
28
|
+
return { observations: root.observations };
|
|
29
|
+
}
|
|
30
|
+
export async function runDomainControlMonitor(manifestInput, previousInput, options) {
|
|
31
|
+
const generatedAt = options.now();
|
|
32
|
+
let rawManifest;
|
|
33
|
+
try {
|
|
34
|
+
rawManifest = JSON.parse(manifestInput.replace(/^\uFEFF/u, ''));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new CliUsageError('Domain-control manifest must be valid JSON.');
|
|
38
|
+
}
|
|
39
|
+
const manifest = verifyDomainControlManifest(rawManifest);
|
|
40
|
+
const entries = manifest.entries.slice(0, Math.min(options.limit, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS));
|
|
41
|
+
const lookups = new Array(entries.length);
|
|
42
|
+
const failures = [];
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
let completed = 0;
|
|
45
|
+
const worker = async () => {
|
|
46
|
+
while (cursor < entries.length) {
|
|
47
|
+
const index = cursor++;
|
|
48
|
+
const entry = entries[index];
|
|
49
|
+
if (!entry)
|
|
50
|
+
break;
|
|
51
|
+
try {
|
|
52
|
+
const classified = classifyQuery(entry.domain);
|
|
53
|
+
const result = await options.executeLookup(classified, { fast: false, compact: false, ...(options.signal ? { signal: options.signal } : {}) });
|
|
54
|
+
lookups[index] = buildCliLookupDocument(entry.domain, classified, result, generatedAt, 'deep');
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
failures.push({ domain: entry.domain, error: boundedCliErrorMessage(cause, 'Lookup failed') });
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
completed += 1;
|
|
61
|
+
options.onSettled?.(completed, entries.length);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
await Promise.all(Array.from({ length: Math.min(options.concurrency, Math.max(entries.length, 1)) }, worker));
|
|
66
|
+
const successful = lookups.filter(Boolean);
|
|
67
|
+
if (!successful.length)
|
|
68
|
+
throw new Error('No domain-control monitor lookup completed successfully.');
|
|
69
|
+
const review = buildCliDomainControlReview(JSON.stringify({
|
|
70
|
+
schema: 'whoisleuth.cli.domain-control-review-input', version: 1, manifest, lookups: successful,
|
|
71
|
+
}), generatedAt);
|
|
72
|
+
const previous = previousSnapshot(previousInput);
|
|
73
|
+
const observations = review.observations;
|
|
74
|
+
const flightRecorder = buildDomainControlFlightRecorder({
|
|
75
|
+
schema: 'whoisleuth.domain-control-flight-recorder.input', version: 1,
|
|
76
|
+
observations: [...previous.observations, ...observations], approvedWindows: [],
|
|
77
|
+
}, generatedAt);
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
schema: CLI_DOMAIN_CONTROL_MONITOR_SCHEMA,
|
|
80
|
+
version: CLI_DOMAIN_CONTROL_MONITOR_VERSION,
|
|
81
|
+
generatedAt,
|
|
82
|
+
manifest: Object.freeze({ digestSha256: manifest.integrity.digestSha256, expiresAt: manifest.expiresAt }),
|
|
83
|
+
collection: Object.freeze({ requested: entries.length, succeeded: observations.length, failed: failures.length, failures: Object.freeze(failures) }),
|
|
84
|
+
observations,
|
|
85
|
+
review: review.review,
|
|
86
|
+
flightRecorder,
|
|
87
|
+
limitations: Object.freeze([
|
|
88
|
+
'This is one bounded run, not a daemon. Scheduling and secure checkpoint retention remain operator-controlled.',
|
|
89
|
+
'The output retains compact normalised observations and errors, not raw RDAP, WHOIS, DNS, HTTP, TLS or page payloads.',
|
|
90
|
+
'A failed source or lookup remains incomplete and does not establish that a previously observed value disappeared.',
|
|
91
|
+
]),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function formatDomainControlMonitor(document) {
|
|
95
|
+
return [
|
|
96
|
+
'One-shot domain control review',
|
|
97
|
+
`Requested ${document.collection.requested}`,
|
|
98
|
+
`Succeeded ${document.collection.succeeded}`,
|
|
99
|
+
`Failed ${document.collection.failed}`,
|
|
100
|
+
`Review ${document.review.state}`,
|
|
101
|
+
`Unexpected ${document.flightRecorder.summary.unexpectedChanges}`,
|
|
102
|
+
'',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, } from "../lib/domain-control-flight-recorder.mjs";
|
|
3
|
+
import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
4
|
+
import { CliUsageError } from "./errors.mjs";
|
|
5
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
6
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.cli.domain-control-review-input';
|
|
7
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.cli.domain-control-review';
|
|
8
|
+
export const CLI_DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
9
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
12
|
+
}
|
|
13
|
+
function state(value) {
|
|
14
|
+
const normalized = typeof value === 'string' ? value.toLowerCase() : '';
|
|
15
|
+
if (normalized === 'success' || normalized === 'complete')
|
|
16
|
+
return 'observed';
|
|
17
|
+
if (normalized === 'partial' || normalized === 'not_found')
|
|
18
|
+
return 'partial';
|
|
19
|
+
if (normalized === 'unsupported' || normalized === 'not_applicable')
|
|
20
|
+
return 'unsupported';
|
|
21
|
+
return 'unavailable';
|
|
22
|
+
}
|
|
23
|
+
function text(value, maximum = 500) {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum);
|
|
27
|
+
return normalized || null;
|
|
28
|
+
}
|
|
29
|
+
function list(value, normalizer = (item) => text(item)) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return [];
|
|
32
|
+
return [...new Set(value.slice(0, 128).flatMap((item) => {
|
|
33
|
+
const normalized = normalizer(item);
|
|
34
|
+
return normalized ? [normalized.toLowerCase()] : [];
|
|
35
|
+
}))].sort().slice(0, 32);
|
|
36
|
+
}
|
|
37
|
+
function hostname(value) {
|
|
38
|
+
return text(value, 253)?.toLowerCase().replace(/\.$/u, '') ?? null;
|
|
39
|
+
}
|
|
40
|
+
function mxValue(value) {
|
|
41
|
+
const item = record(value);
|
|
42
|
+
const exchange = hostname(item.exchange ?? item.host ?? item.value);
|
|
43
|
+
if (!exchange)
|
|
44
|
+
return text(value);
|
|
45
|
+
const priority = Number.isSafeInteger(item.priority) && Number(item.priority) >= 0 ? Number(item.priority) : null;
|
|
46
|
+
return priority === null ? exchange : `${priority} ${exchange}`;
|
|
47
|
+
}
|
|
48
|
+
function caaValue(value) {
|
|
49
|
+
const item = record(value);
|
|
50
|
+
const tag = text(item.tag, 32)?.toLowerCase();
|
|
51
|
+
const recordValue = text(item.value, 500)?.toLowerCase();
|
|
52
|
+
if (!tag || !recordValue)
|
|
53
|
+
return text(value);
|
|
54
|
+
const critical = Number.isSafeInteger(item.critical) ? Number(item.critical) : Number.isSafeInteger(item.flags) ? Number(item.flags) : 0;
|
|
55
|
+
return `${critical} ${tag} ${recordValue}`;
|
|
56
|
+
}
|
|
57
|
+
function dsValue(value) {
|
|
58
|
+
const item = record(value);
|
|
59
|
+
const keyTag = Number.isSafeInteger(item.keyTag) ? item.keyTag : null;
|
|
60
|
+
const algorithm = Number.isSafeInteger(item.algorithm) ? item.algorithm : null;
|
|
61
|
+
const digestType = Number.isSafeInteger(item.digestType) ? item.digestType : null;
|
|
62
|
+
const digest = text(item.digest, 512)?.toLowerCase();
|
|
63
|
+
return keyTag !== null && algorithm !== null && digestType !== null && digest
|
|
64
|
+
? `${keyTag} ${algorithm} ${digestType} ${digest}`
|
|
65
|
+
: text(value);
|
|
66
|
+
}
|
|
67
|
+
function field(id, source, sourceState, values) {
|
|
68
|
+
return Object.freeze({ id, source, state: sourceState, values: Object.freeze([...values].sort()) });
|
|
69
|
+
}
|
|
70
|
+
function diagnostic(document, key) {
|
|
71
|
+
return state(record(document.diagnostics)[key] && record(record(document.diagnostics)[key]).status);
|
|
72
|
+
}
|
|
73
|
+
function registryParsed(document) {
|
|
74
|
+
const rdapState = diagnostic(document, 'rdap');
|
|
75
|
+
const whoisState = diagnostic(document, 'whois');
|
|
76
|
+
const rdap = record(record(document.rdap).parsed);
|
|
77
|
+
if (rdapState === 'observed' && Object.keys(rdap).length)
|
|
78
|
+
return { parsed: rdap, source: 'Registry RDAP', sourceState: rdapState };
|
|
79
|
+
const whois = record(record(document.whois).parsed);
|
|
80
|
+
return { parsed: whois, source: 'WHOIS', sourceState: whoisState };
|
|
81
|
+
}
|
|
82
|
+
function lockValue(parsed) {
|
|
83
|
+
const statuses = list(parsed.statuses ?? parsed.status, (item) => text(item, 120)?.replace(/[\s_-]+/gu, '').toLowerCase() ?? null);
|
|
84
|
+
return [statuses.some((item) => item.includes('transferprohibited')) ? 'required' : 'not_required'];
|
|
85
|
+
}
|
|
86
|
+
function pageIdentityValue(availability) {
|
|
87
|
+
const identity = record(availability.pageIdentity);
|
|
88
|
+
const components = [
|
|
89
|
+
text(identity.bodySha256 ?? availability.pageBodySha256, 64),
|
|
90
|
+
text(identity.faviconHash ?? availability.faviconHash, 128),
|
|
91
|
+
text(identity.title ?? availability.pageTitle, 240),
|
|
92
|
+
].filter((item) => Boolean(item));
|
|
93
|
+
return components.length ? [components.join(' | ').toLowerCase()] : [];
|
|
94
|
+
}
|
|
95
|
+
export function domainControlObservationFromSavedLookup(document) {
|
|
96
|
+
const availability = record(document.availability);
|
|
97
|
+
const dns = record(availability.dns);
|
|
98
|
+
const dnsRecords = record(dns.records);
|
|
99
|
+
const delegation = record(dns.delegation);
|
|
100
|
+
const delegationRecords = record(delegation.records);
|
|
101
|
+
const tls = record(availability.tls);
|
|
102
|
+
const tlsState = state(tls.status);
|
|
103
|
+
const certificate = record(tls.certificate);
|
|
104
|
+
const publicKey = record(certificate.publicKey);
|
|
105
|
+
const http = record(availability.http);
|
|
106
|
+
const httpState = state(http.status);
|
|
107
|
+
const registry = registryParsed(document);
|
|
108
|
+
const rdapParsed = record(record(document.rdap).parsed);
|
|
109
|
+
const rdapState = diagnostic(document, 'rdap');
|
|
110
|
+
const dnsState = state(dns.status);
|
|
111
|
+
const fields = [
|
|
112
|
+
field('registrar', registry.source, registry.sourceState, list([record(registry.parsed.registrar).name ?? registry.parsed.registrar], (item) => text(item, 300))),
|
|
113
|
+
field('registrar_lock', registry.source, registry.sourceState, registry.sourceState === 'observed' ? lockValue(registry.parsed) : []),
|
|
114
|
+
field('registry_dnssec', 'Registry RDAP', rdapState, list([rdapParsed.dnssec], (item) => text(item, 80))),
|
|
115
|
+
field('registry_nameservers', 'Registry RDAP', rdapState, list(rdapParsed.nameservers, hostname)),
|
|
116
|
+
field('whois_nameservers', 'WHOIS', diagnostic(document, 'whois'), list(record(record(document.whois).parsed).nameservers, hostname)),
|
|
117
|
+
field('delegated_nameservers', 'DNS', dnsState, list(dnsRecords.ns, hostname)),
|
|
118
|
+
field('delegation_ds', 'DNS delegation', state(delegation.status ?? dns.status), list(delegationRecords.ds ?? rdapParsed.dsData, dsValue)),
|
|
119
|
+
field('mail_exchangers', 'DNS', dnsState, list(dnsRecords.mx, mxValue)),
|
|
120
|
+
field('caa_policy', 'DNS', dnsState, list(record(dns.caaPolicy).records ?? dnsRecords.caa, caaValue)),
|
|
121
|
+
field('tls_certificate', 'TLS', tlsState, list([certificate.fingerprintSha256 ?? tls.fingerprintSha256], (item) => text(item, 128))),
|
|
122
|
+
field('tls_public_key', 'TLS', tlsState, list([publicKey.fingerprintSha256 ?? tls.spkiSha256], (item) => text(item, 128))),
|
|
123
|
+
field('http_origin', 'HTTP', httpState, list([http.finalOrigin ?? availability.httpFinalOrigin], (item) => text(item, 500))),
|
|
124
|
+
field('page_identity', 'Static page identity', state(record(availability.pageIdentity).status ?? availability.pageIdentityStatus ?? http.status), pageIdentityValue(availability)),
|
|
125
|
+
];
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
domain: document.registrableDomain,
|
|
128
|
+
observedAt: document.generatedAt,
|
|
129
|
+
collectionDepth: document.mode,
|
|
130
|
+
fields: Object.freeze(fields.filter((item) => DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.includes(item.id))),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function buildCliDomainControlReview(inputText, generatedAt = new Date().toISOString()) {
|
|
134
|
+
if (Buffer.byteLength(inputText, 'utf8') > MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES) {
|
|
135
|
+
throw new CliUsageError(`Domain-control review input is limited to ${MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES} bytes.`);
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(inputText.replace(/^\uFEFF/u, ''));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new CliUsageError('Domain-control review input must be valid JSON.');
|
|
143
|
+
}
|
|
144
|
+
const input = record(parsed);
|
|
145
|
+
if (input.schema !== CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || input.version !== 1 || !Array.isArray(input.lookups)) {
|
|
146
|
+
throw new CliUsageError(`Domain-control review input must use ${CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
|
|
147
|
+
}
|
|
148
|
+
if (Object.keys(input).some((key) => !new Set(['schema', 'version', 'manifest', 'lookups']).has(key))) {
|
|
149
|
+
throw new CliUsageError('Domain-control review input contains an unsupported field.');
|
|
150
|
+
}
|
|
151
|
+
if (input.lookups.length < 1 || input.lookups.length > 100) {
|
|
152
|
+
throw new CliUsageError('Domain-control review requires from 1 to 100 saved Lookup documents.');
|
|
153
|
+
}
|
|
154
|
+
const manifest = verifyDomainControlManifest(input.manifest);
|
|
155
|
+
const lookups = input.lookups.map((item, index) => parseSavedLookupDocument(JSON.stringify(item), { label: `Lookup ${index + 1}` }));
|
|
156
|
+
const latest = new Map();
|
|
157
|
+
for (const lookup of lookups) {
|
|
158
|
+
const current = latest.get(lookup.registrableDomain);
|
|
159
|
+
if (!current || Date.parse(lookup.generatedAt) > Date.parse(current.generatedAt))
|
|
160
|
+
latest.set(lookup.registrableDomain, lookup);
|
|
161
|
+
}
|
|
162
|
+
const observations = [...latest.values()].map(domainControlObservationFromSavedLookup);
|
|
163
|
+
const review = reviewDomainControlManifest({
|
|
164
|
+
schema: DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
|
|
165
|
+
version: 1,
|
|
166
|
+
manifest,
|
|
167
|
+
observations: observations.map((item) => ({
|
|
168
|
+
domain: item.domain,
|
|
169
|
+
fields: Object.fromEntries(item.fields.flatMap((candidate) => {
|
|
170
|
+
const mapping = {
|
|
171
|
+
registry_nameservers: 'nameservers',
|
|
172
|
+
delegation_ds: 'ds',
|
|
173
|
+
mail_exchangers: 'mx',
|
|
174
|
+
caa_policy: 'caa',
|
|
175
|
+
tls_certificate: 'tlsIssuer',
|
|
176
|
+
tls_public_key: 'tlsSpkiSha256',
|
|
177
|
+
registrar_lock: 'registrarLock',
|
|
178
|
+
};
|
|
179
|
+
const target = mapping[candidate.id];
|
|
180
|
+
if (!target)
|
|
181
|
+
return [];
|
|
182
|
+
const candidateValues = candidate.id === 'tls_certificate'
|
|
183
|
+
? list([record(record(record(latest.get(item.domain)?.availability).tls).certificate).issuer], (value) => {
|
|
184
|
+
const issuer = record(value);
|
|
185
|
+
const commonNames = Array.isArray(issuer.commonNames) ? issuer.commonNames : [];
|
|
186
|
+
const organisationNames = Array.isArray(issuer.organizationNames) ? issuer.organizationNames : [];
|
|
187
|
+
return text(commonNames[0] ?? organisationNames[0] ?? value, 300);
|
|
188
|
+
})
|
|
189
|
+
: candidate.values;
|
|
190
|
+
return [[target, { state: candidate.state, values: candidateValues, source: candidate.source, observedAt: item.observedAt }]];
|
|
191
|
+
})),
|
|
192
|
+
})),
|
|
193
|
+
}, generatedAt);
|
|
194
|
+
return Object.freeze({
|
|
195
|
+
schema: CLI_DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
196
|
+
version: CLI_DOMAIN_CONTROL_REVIEW_VERSION,
|
|
197
|
+
generatedAt,
|
|
198
|
+
review,
|
|
199
|
+
observations: Object.freeze(observations),
|
|
200
|
+
input: Object.freeze({
|
|
201
|
+
lookupsReceived: lookups.length,
|
|
202
|
+
latestDomainObservations: observations.length,
|
|
203
|
+
ignoredHistoricalLookups: lookups.length - observations.length,
|
|
204
|
+
}),
|
|
205
|
+
limitations: Object.freeze([
|
|
206
|
+
'The review uses only supplied saved Lookup documents and performs no request.',
|
|
207
|
+
'The newest supplied observation per domain is used for desired-state comparison; all supplied observations remain available to a separate flight-recorder review.',
|
|
208
|
+
'Raw RDAP, WHOIS, HTTP, TLS, and page payloads are not copied into this output.',
|
|
209
|
+
]),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export function buildControlReviewInput(manifest, lookupDocuments) {
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
schema: CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
|
|
215
|
+
version: 1,
|
|
216
|
+
manifest,
|
|
217
|
+
lookups: Object.freeze(lookupDocuments),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
export function formatCliDomainControlReview(document) {
|
|
221
|
+
const review = document.review;
|
|
222
|
+
return [
|
|
223
|
+
'Domain-control evidence review',
|
|
224
|
+
`State ${review.state}`,
|
|
225
|
+
`Domains ${review.domains.length}`,
|
|
226
|
+
`Lookups ${document.input.lookupsReceived}`,
|
|
227
|
+
`Drift ${review.counts.drift ?? 0}`,
|
|
228
|
+
`Incomplete ${(review.counts.partial ?? 0) + (review.counts.unavailable ?? 0) + (review.counts.unsupported ?? 0)}`,
|
|
229
|
+
'',
|
|
230
|
+
...review.domains.map((item) => `${item.domain} ${item.state}`),
|
|
231
|
+
'',
|
|
232
|
+
...document.limitations.map((item) => `Limitation: ${item}`),
|
|
233
|
+
'',
|
|
234
|
+
].join('\n');
|
|
235
|
+
}
|
package/cli/errors.mjs
CHANGED
|
@@ -18,4 +18,10 @@ function boundedCliErrorMessage(error, fallback = 'Unexpected command failure')
|
|
|
18
18
|
.trim()
|
|
19
19
|
.slice(0, MAX_CLI_ERROR_MESSAGE_LENGTH) || fallback;
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
function boundedCliInputError(error, label) {
|
|
22
|
+
if (error instanceof CliUsageError)
|
|
23
|
+
return error;
|
|
24
|
+
const safeLabel = label.replace(/[^A-Za-z0-9 ()_-]+/gu, '').trim().slice(0, 80) || 'Input';
|
|
25
|
+
return new CliUsageError(`${safeLabel} could not be read as a bounded regular file.`);
|
|
26
|
+
}
|
|
27
|
+
export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage, boundedCliInputError, };
|
|
@@ -5,6 +5,7 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
5
5
|
let search = null;
|
|
6
6
|
let reveal = false;
|
|
7
7
|
let requireMatch = false;
|
|
8
|
+
let expectedContentDigest = null;
|
|
8
9
|
let output = 'terminal';
|
|
9
10
|
let quiet = false;
|
|
10
11
|
let color = true;
|
|
@@ -43,6 +44,15 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
43
44
|
throw new CliUsageError('--require-match may be supplied only once.');
|
|
44
45
|
requireMatch = true;
|
|
45
46
|
}
|
|
47
|
+
else if (argument === '--expect-content-digest') {
|
|
48
|
+
if (expectedContentDigest !== null)
|
|
49
|
+
throw new CliUsageError('--expect-content-digest may be supplied only once.');
|
|
50
|
+
const value = argv[++index];
|
|
51
|
+
if (!value || !/^sha256:[a-f0-9]{64}$/u.test(value)) {
|
|
52
|
+
throw new CliUsageError('--expect-content-digest requires sha256 followed by 64 lowercase hexadecimal characters.');
|
|
53
|
+
}
|
|
54
|
+
expectedContentDigest = value;
|
|
55
|
+
}
|
|
46
56
|
else if (argument === '--quiet')
|
|
47
57
|
quiet = true;
|
|
48
58
|
else if (argument === '--no-color')
|
|
@@ -67,6 +77,7 @@ export function parseInspectArchiveArguments(argv) {
|
|
|
67
77
|
search,
|
|
68
78
|
reveal,
|
|
69
79
|
requireMatch,
|
|
80
|
+
expectedContentDigest,
|
|
70
81
|
output,
|
|
71
82
|
quiet,
|
|
72
83
|
color,
|
|
@@ -71,6 +71,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
71
71
|
search: args.search,
|
|
72
72
|
reveal: args.reveal,
|
|
73
73
|
requireMatch: args.requireMatch,
|
|
74
|
+
expectedContentDigest: args.expectedContentDigest,
|
|
74
75
|
});
|
|
75
76
|
if (!args.quiet) {
|
|
76
77
|
dependencies.stdout.write(args.output === 'json'
|