@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- 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 +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
2
1
|
import { classifyQuery } from "../lib/classify.mjs";
|
|
3
2
|
import { normalizeSelectedDnsResolvers } from "../lib/dns-resolver-selection.mjs";
|
|
4
3
|
import { runUnifiedLookup } from "../lib/lookup.mjs";
|
|
5
4
|
import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
|
|
6
|
-
import { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist,
|
|
5
|
+
import { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist, runDiscoveryScanChunks, } from "./discovery-scan.mjs";
|
|
7
6
|
import { updateDiscoveryObservationSnapshot } from "./discovery-observation-snapshot.mjs";
|
|
8
7
|
import { generateDiscoveryCandidates } from "./discovery-workflow.mjs";
|
|
9
8
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
@@ -12,13 +11,13 @@ import { formatJsonDocument } from "./formatters/json.mjs";
|
|
|
12
11
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
13
12
|
import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
|
|
14
13
|
import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
|
|
15
|
-
async function readAllowlist(source, dependencies, classify) {
|
|
14
|
+
async function readAllowlist(source, dependencies, context, classify) {
|
|
16
15
|
if (!source)
|
|
17
16
|
return new Set();
|
|
18
17
|
try {
|
|
19
18
|
const text = dependencies.readDiscoveryAllowlist
|
|
20
19
|
? await dependencies.readDiscoveryAllowlist(source)
|
|
21
|
-
: await
|
|
20
|
+
: await context.readInput(source, 64 * 1024, 'Discovery scan allowlist');
|
|
22
21
|
return parseDiscoveryScanAllowlist(text, classify);
|
|
23
22
|
}
|
|
24
23
|
catch (error) {
|
|
@@ -61,7 +60,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
61
60
|
throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid DNS resolver selection'));
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
|
-
const allowlist = await readAllowlist(args.allowlistSource, dependencies, classify);
|
|
63
|
+
const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
|
|
65
64
|
const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
|
|
66
65
|
const checkpoint = args.checkpoint
|
|
67
66
|
? await checkpointWriter({
|
|
@@ -75,6 +74,8 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
75
74
|
: null;
|
|
76
75
|
const indicator = context.beginProgress(`Collecting 0 of ${queries.length} generated candidates`);
|
|
77
76
|
let completed = checkpoint?.initialResults.length || 0;
|
|
77
|
+
const resumedItems = new Map((checkpoint?.initialResults ?? []).map((item) => [item.index, item]));
|
|
78
|
+
const settledItems = new Map();
|
|
78
79
|
if (completed)
|
|
79
80
|
indicator.update(`Resumed ${completed} of ${queries.length} generated candidates`);
|
|
80
81
|
let checkpointFailure = null;
|
|
@@ -90,12 +91,23 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
90
91
|
...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
|
|
91
92
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
92
93
|
onItemSettled: (item) => {
|
|
94
|
+
const observed = {
|
|
95
|
+
...item,
|
|
96
|
+
observedAt: context.now(),
|
|
97
|
+
collectionOrigin: 'current_run',
|
|
98
|
+
};
|
|
99
|
+
settledItems.set(item.index, observed);
|
|
93
100
|
completed += 1;
|
|
94
101
|
indicator.update(`Collected ${completed} of ${queries.length} generated candidates`);
|
|
95
102
|
eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
|
|
96
|
-
checkpoint?.record(
|
|
103
|
+
checkpoint?.record(observed);
|
|
97
104
|
},
|
|
98
105
|
});
|
|
106
|
+
items = items.map((item) => resumedItems.get(item.index) ?? settledItems.get(item.index) ?? {
|
|
107
|
+
...item,
|
|
108
|
+
observedAt: null,
|
|
109
|
+
collectionOrigin: 'current_run',
|
|
110
|
+
});
|
|
99
111
|
}
|
|
100
112
|
finally {
|
|
101
113
|
context.endProgress();
|
|
@@ -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) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { DEFAULT_DISCOVERY_TLDS, MAX_DISCOVERY_DICTIONARY_BYTES, normalizeDiscoveryTlds, readDiscoveryDictionaryBounded, } from "./discover.mjs";
|
|
2
|
+
import { DEFAULT_DISCOVERY_TLDS, MAX_DISCOVERY_DICTIONARY_BYTES, normalizeDiscoveryTlds, } from "./discover.mjs";
|
|
4
3
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
5
4
|
async function generateDiscoveryCandidates(args, dependencies, context) {
|
|
6
5
|
const seed = args.seed || await context.readSingleInput();
|
|
@@ -29,7 +28,7 @@ async function generateDiscoveryCandidates(args, dependencies, context) {
|
|
|
29
28
|
try {
|
|
30
29
|
dictionaryText = dependencies.readDiscoveryDictionary
|
|
31
30
|
? await dependencies.readDiscoveryDictionary(args.dictionarySource)
|
|
32
|
-
: await
|
|
31
|
+
: await context.readInput(args.dictionarySource, MAX_DISCOVERY_DICTIONARY_BYTES, 'Discovery dictionary');
|
|
33
32
|
}
|
|
34
33
|
catch (error) {
|
|
35
34
|
if (error instanceof CliUsageError)
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withTimeout } from "../lib/abort.mjs";
|
|
2
|
-
const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
|
|
3
|
-
const DOCTOR_VERSION = 1;
|
|
2
|
+
export const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
|
|
3
|
+
export const DOCTOR_VERSION = 1;
|
|
4
4
|
const MINIMUM_NODE_MAJOR = 24;
|
|
5
5
|
const NETWORK_HOST = 'whois.iana.org';
|
|
6
6
|
const NETWORK_QUERY = 'example.com';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { classifyQuery } from "../lib/classify.mjs";
|
|
2
|
+
import { scanBoundedJson } from "../lib/bounded-json.mjs";
|
|
2
3
|
import { buildDomainControlFlightRecorder, } from "../lib/domain-control-flight-recorder.mjs";
|
|
3
4
|
import { verifyDomainControlManifest } from "../lib/domain-control-manifest.mjs";
|
|
4
5
|
import { buildCliLookupDocument } from "./formatters/json.mjs";
|
|
@@ -7,16 +8,27 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
|
7
8
|
export const CLI_DOMAIN_CONTROL_MONITOR_SCHEMA = 'whoisleuth.cli.domain-control-monitor';
|
|
8
9
|
export const CLI_DOMAIN_CONTROL_MONITOR_VERSION = 1;
|
|
9
10
|
export const MAX_DOMAIN_CONTROL_MONITOR_DOMAINS = 20;
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
return { observations: [] };
|
|
13
|
-
let parsed;
|
|
11
|
+
function parseMonitorJson(input, label) {
|
|
12
|
+
const normalized = input.replace(/^\uFEFF/u, '');
|
|
14
13
|
try {
|
|
15
|
-
|
|
14
|
+
scanBoundedJson(normalized);
|
|
15
|
+
return JSON.parse(normalized);
|
|
16
16
|
}
|
|
17
|
-
catch {
|
|
18
|
-
|
|
17
|
+
catch (cause) {
|
|
18
|
+
const detail = cause instanceof Error ? cause.message : '';
|
|
19
|
+
if (detail === 'Artefact input is not valid JSON.') {
|
|
20
|
+
throw new CliUsageError(`${label} must be valid JSON.`);
|
|
21
|
+
}
|
|
22
|
+
if (detail.startsWith('Artefact JSON ')) {
|
|
23
|
+
throw new CliUsageError(`${label} ${detail.slice('Artefact JSON '.length)}`);
|
|
24
|
+
}
|
|
25
|
+
throw new CliUsageError(`${label} must satisfy the bounded JSON structure contract.`);
|
|
19
26
|
}
|
|
27
|
+
}
|
|
28
|
+
function previousSnapshot(input) {
|
|
29
|
+
if (!input)
|
|
30
|
+
return { observations: [] };
|
|
31
|
+
const parsed = parseMonitorJson(input, 'Previous monitor snapshot');
|
|
20
32
|
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
21
33
|
if (root.schema !== CLI_DOMAIN_CONTROL_MONITOR_SCHEMA || root.version !== CLI_DOMAIN_CONTROL_MONITOR_VERSION || !Array.isArray(root.observations)) {
|
|
22
34
|
throw new CliUsageError(`Previous monitor snapshot must use ${CLI_DOMAIN_CONTROL_MONITOR_SCHEMA} version ${CLI_DOMAIN_CONTROL_MONITOR_VERSION}.`);
|
|
@@ -29,14 +41,9 @@ function previousSnapshot(input) {
|
|
|
29
41
|
}
|
|
30
42
|
export async function runDomainControlMonitor(manifestInput, previousInput, options) {
|
|
31
43
|
const generatedAt = options.now();
|
|
32
|
-
|
|
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
|
-
}
|
|
44
|
+
const rawManifest = parseMonitorJson(manifestInput, 'Domain-control manifest');
|
|
39
45
|
const manifest = verifyDomainControlManifest(rawManifest);
|
|
46
|
+
const previous = previousSnapshot(previousInput);
|
|
40
47
|
const entries = manifest.entries.slice(0, Math.min(options.limit, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS));
|
|
41
48
|
const lookups = new Array(entries.length);
|
|
42
49
|
const failures = [];
|
|
@@ -69,7 +76,6 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
|
|
|
69
76
|
const review = buildCliDomainControlReview(JSON.stringify({
|
|
70
77
|
schema: 'whoisleuth.cli.domain-control-review-input', version: 1, manifest, lookups: successful,
|
|
71
78
|
}), generatedAt);
|
|
72
|
-
const previous = previousSnapshot(previousInput);
|
|
73
79
|
const observations = review.observations;
|
|
74
80
|
const flightRecorder = buildDomainControlFlightRecorder({
|
|
75
81
|
schema: 'whoisleuth.domain-control-flight-recorder.input', version: 1,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, scanBoundedJson, } from "../lib/bounded-json.mjs";
|
|
2
3
|
import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, } from "../lib/domain-control-flight-recorder.mjs";
|
|
3
4
|
import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
4
5
|
import { CliUsageError } from "./errors.mjs";
|
|
@@ -7,6 +8,10 @@ export const CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.cli.domain-con
|
|
|
7
8
|
export const CLI_DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.cli.domain-control-review';
|
|
8
9
|
export const CLI_DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
9
10
|
export const MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS = 100;
|
|
12
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH = MAX_BOUNDED_JSON_DEPTH + 2;
|
|
13
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS = MAX_BOUNDED_JSON_KEYS * (MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS + 1);
|
|
14
|
+
export const MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES = MAX_BOUNDED_JSON_VALUES * (MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS + 1);
|
|
10
15
|
function record(value) {
|
|
11
16
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
12
17
|
}
|
|
@@ -134,9 +139,27 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
|
|
|
134
139
|
if (Buffer.byteLength(inputText, 'utf8') > MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES) {
|
|
135
140
|
throw new CliUsageError(`Domain-control review input is limited to ${MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES} bytes.`);
|
|
136
141
|
}
|
|
142
|
+
const normalizedInput = inputText.replace(/^\uFEFF/u, '');
|
|
143
|
+
try {
|
|
144
|
+
scanBoundedJson(normalizedInput, {
|
|
145
|
+
maximumDepth: MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH,
|
|
146
|
+
maximumKeys: MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS,
|
|
147
|
+
maximumValues: MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
const detail = cause instanceof Error ? cause.message : '';
|
|
152
|
+
if (detail === 'Artefact input is not valid JSON.') {
|
|
153
|
+
throw new CliUsageError('Domain-control review input must be valid JSON.');
|
|
154
|
+
}
|
|
155
|
+
const boundedDetail = detail.replace(/^Artefact JSON /u, '');
|
|
156
|
+
throw new CliUsageError(boundedDetail
|
|
157
|
+
? `Domain-control review input ${boundedDetail}`
|
|
158
|
+
: 'Domain-control review input must be valid JSON.');
|
|
159
|
+
}
|
|
137
160
|
let parsed;
|
|
138
161
|
try {
|
|
139
|
-
parsed = JSON.parse(
|
|
162
|
+
parsed = JSON.parse(normalizedInput);
|
|
140
163
|
}
|
|
141
164
|
catch {
|
|
142
165
|
throw new CliUsageError('Domain-control review input must be valid JSON.');
|
|
@@ -148,7 +171,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
|
|
|
148
171
|
if (Object.keys(input).some((key) => !new Set(['schema', 'version', 'manifest', 'lookups']).has(key))) {
|
|
149
172
|
throw new CliUsageError('Domain-control review input contains an unsupported field.');
|
|
150
173
|
}
|
|
151
|
-
if (input.lookups.length < 1 || input.lookups.length >
|
|
174
|
+
if (input.lookups.length < 1 || input.lookups.length > MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS) {
|
|
152
175
|
throw new CliUsageError('Domain-control review requires from 1 to 100 saved Lookup documents.');
|
|
153
176
|
}
|
|
154
177
|
const manifest = verifyDomainControlManifest(input.manifest);
|
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, };
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
2
1
|
import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
|
|
3
2
|
import { formatArchiveInspection, inspectWorkspaceArchive, } from "./archive-inspect.mjs";
|
|
4
3
|
import { MAX_SIGNING_KEY_FILE_BYTES, formatEvidenceSignatureVerification, signEvidencePackage, verifyEvidencePackageSignature, } from "./evidence-signing.mjs";
|
|
5
4
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
6
5
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
7
6
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
8
|
-
import {
|
|
7
|
+
import { readCliTextInput } from "./input.mjs";
|
|
9
8
|
export function isEvidenceCommand(args) {
|
|
10
9
|
return args.action === 'inspect-archive'
|
|
11
10
|
|| args.action === 'sign-artifact'
|
|
@@ -22,11 +21,10 @@ async function readArtifact(source, label, dependencies) {
|
|
|
22
21
|
try {
|
|
23
22
|
return dependencies.readArtifactInput
|
|
24
23
|
? await dependencies.readArtifactInput(source)
|
|
25
|
-
: await
|
|
26
|
-
|
|
27
|
-
: dependencies.stdin, {
|
|
28
|
-
limit: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
24
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
25
|
+
maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
29
26
|
label,
|
|
27
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
30
28
|
});
|
|
31
29
|
}
|
|
32
30
|
catch (error) {
|
|
@@ -35,11 +33,15 @@ async function readArtifact(source, label, dependencies) {
|
|
|
35
33
|
throw new CliUsageError(`Could not read ${label.toLowerCase()}: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
|
-
async function readKey(source, label, injected) {
|
|
36
|
+
async function readKey(source, label, injected, dependencies) {
|
|
39
37
|
try {
|
|
40
38
|
return injected
|
|
41
39
|
? await injected(source)
|
|
42
|
-
: await
|
|
40
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
41
|
+
maximumBytes: MAX_SIGNING_KEY_FILE_BYTES,
|
|
42
|
+
label,
|
|
43
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
44
|
+
});
|
|
43
45
|
}
|
|
44
46
|
catch (error) {
|
|
45
47
|
if (error instanceof CliUsageError)
|
|
@@ -50,7 +52,11 @@ async function readKey(source, label, injected) {
|
|
|
50
52
|
async function readPassphrase(source, dependencies) {
|
|
51
53
|
const passphraseText = dependencies.readPassphraseFile
|
|
52
54
|
? await dependencies.readPassphraseFile(source)
|
|
53
|
-
: await
|
|
55
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
56
|
+
maximumBytes: MAX_OFFLINE_PASSPHRASE_FILE_BYTES,
|
|
57
|
+
label: 'Passphrase file',
|
|
58
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
59
|
+
});
|
|
54
60
|
const passphrase = passphraseText.replace(/\r?\n$/u, '');
|
|
55
61
|
if (!passphrase || /[\r\n\u0000]/u.test(passphrase)) {
|
|
56
62
|
throw new CliUsageError('Passphrase file must contain exactly one non-empty UTF-8 line.');
|
|
@@ -85,7 +91,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
85
91
|
if (!input.trim()) {
|
|
86
92
|
throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
|
|
87
93
|
}
|
|
88
|
-
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
|
|
94
|
+
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile, dependencies);
|
|
89
95
|
const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
|
|
90
96
|
dependencies.stdout.write(formatJsonDocument(signed));
|
|
91
97
|
return EXIT_CODES.SUCCESS;
|
|
@@ -95,7 +101,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
95
101
|
throw new CliUsageError('verify-signature requires one signed package file or a package on stdin.');
|
|
96
102
|
}
|
|
97
103
|
const publicKey = args.publicKeySource
|
|
98
|
-
? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile)
|
|
104
|
+
? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile, dependencies)
|
|
99
105
|
: null;
|
|
100
106
|
const report = await verifyEvidencePackageSignature(input, publicKey);
|
|
101
107
|
if (!args.quiet) {
|
package/cli/evidence-signing.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
3
|
+
import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { hasVerifiedApplicableIntegrity, hasVerifiedArtifactStructure, MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
5
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
5
6
|
export const SIGNED_EVIDENCE_PACKAGE_SCHEMA = 'whoisleuth.signed-evidence-package';
|
|
6
|
-
export const SIGNED_EVIDENCE_PACKAGE_VERSION =
|
|
7
|
+
export const SIGNED_EVIDENCE_PACKAGE_VERSION = 2;
|
|
8
|
+
export const LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION = 1;
|
|
9
|
+
export const EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA = 'whoisleuth.evidence-signature-verification';
|
|
10
|
+
export const EVIDENCE_SIGNATURE_VERIFICATION_VERSION = 2;
|
|
7
11
|
export const EVIDENCE_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
8
|
-
export const EVIDENCE_SIGNATURE_CANONICALIZATION =
|
|
12
|
+
export const EVIDENCE_SIGNATURE_CANONICALIZATION = SORTED_JSON_V2;
|
|
9
13
|
export const MAX_SIGNING_KEY_FILE_BYTES = 16 * 1024;
|
|
10
14
|
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
11
15
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
@@ -23,23 +27,7 @@ function record(value) {
|
|
|
23
27
|
: null;
|
|
24
28
|
}
|
|
25
29
|
function parseObject(raw, label) {
|
|
26
|
-
|
|
27
|
-
throw new TypeError(`${label} must be UTF-8 JSON text.`);
|
|
28
|
-
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
29
|
-
if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
|
|
30
|
-
throw new TypeError(`${label} must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
|
|
31
|
-
}
|
|
32
|
-
let parsed;
|
|
33
|
-
try {
|
|
34
|
-
parsed = JSON.parse(raw);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new TypeError(`${label} must be valid JSON.`);
|
|
38
|
-
}
|
|
39
|
-
const value = record(parsed);
|
|
40
|
-
if (!value)
|
|
41
|
-
throw new TypeError(`${label} must contain one JSON object.`);
|
|
42
|
-
return value;
|
|
30
|
+
return parseBoundedJsonObject(raw, { label, maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES });
|
|
43
31
|
}
|
|
44
32
|
function hasExactKeys(value, expected) {
|
|
45
33
|
const keys = Object.keys(value);
|
|
@@ -54,6 +42,12 @@ function timestamp(value) {
|
|
|
54
42
|
throw new TypeError('Signature time must be a valid timestamp.');
|
|
55
43
|
return new Date(parsed).toISOString();
|
|
56
44
|
}
|
|
45
|
+
function canonicalTimestamp(value) {
|
|
46
|
+
const normalized = timestamp(value);
|
|
47
|
+
if (value !== normalized)
|
|
48
|
+
throw new TypeError('Signature time must use canonical UTC timestamp text.');
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
57
51
|
function importPrivateKey(pem) {
|
|
58
52
|
if (Buffer.byteLength(pem, 'utf8') > MAX_SIGNING_KEY_FILE_BYTES) {
|
|
59
53
|
throw new TypeError(`Private key file exceeds ${MAX_SIGNING_KEY_FILE_BYTES} bytes.`);
|
|
@@ -96,8 +90,8 @@ function spkiDer(key) {
|
|
|
96
90
|
function keyId(publicDer) {
|
|
97
91
|
return createHash('sha256').update(publicDer).digest('hex');
|
|
98
92
|
}
|
|
99
|
-
function payloadBytes(payload) {
|
|
100
|
-
return Buffer.from(
|
|
93
|
+
function payloadBytes(payload, canonicalization) {
|
|
94
|
+
return Buffer.from(canonicalArtifactJsonFor(payload, canonicalization), 'utf8');
|
|
101
95
|
}
|
|
102
96
|
async function signableArtifact(raw) {
|
|
103
97
|
const artifact = parseObject(raw, 'Evidence artefact');
|
|
@@ -105,6 +99,9 @@ async function signableArtifact(raw) {
|
|
|
105
99
|
if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
100
|
throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
|
|
107
101
|
}
|
|
102
|
+
if (!hasVerifiedArtifactStructure(verification) || !hasVerifiedApplicableIntegrity(verification)) {
|
|
103
|
+
throw new TypeError('Evidence signing requires verified structure and a verified applicable integrity contract.');
|
|
104
|
+
}
|
|
108
105
|
return { artifact, verification };
|
|
109
106
|
}
|
|
110
107
|
export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Date().toISOString()) {
|
|
@@ -124,7 +121,7 @@ export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Dat
|
|
|
124
121
|
canonicalization: EVIDENCE_SIGNATURE_CANONICALIZATION,
|
|
125
122
|
publicKeySpkiDerBase64: publicDer.toString('base64'),
|
|
126
123
|
keyIdSha256: keyId(publicDer),
|
|
127
|
-
valueBase64: sign(null, payloadBytes(payload), privateKey).toString('base64'),
|
|
124
|
+
valueBase64: sign(null, payloadBytes(payload, SORTED_JSON_V2), privateKey).toString('base64'),
|
|
128
125
|
}),
|
|
129
126
|
});
|
|
130
127
|
}
|
|
@@ -134,12 +131,11 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
134
131
|
const artifact = record(value.artifact);
|
|
135
132
|
if (!hasExactKeys(value, PACKAGE_KEYS)
|
|
136
133
|
|| value.schema !== SIGNED_EVIDENCE_PACKAGE_SCHEMA
|
|
137
|
-
|| value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION
|
|
134
|
+
|| (value.version !== LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION && value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION)
|
|
138
135
|
|| !artifact
|
|
139
136
|
|| !signature
|
|
140
137
|
|| !hasExactKeys(signature, SIGNATURE_KEYS)
|
|
141
138
|
|| signature.algorithm !== EVIDENCE_SIGNATURE_ALGORITHM
|
|
142
|
-
|| signature.canonicalization !== EVIDENCE_SIGNATURE_CANONICALIZATION
|
|
143
139
|
|| typeof signature.publicKeySpkiDerBase64 !== 'string'
|
|
144
140
|
|| signature.publicKeySpkiDerBase64.length > 4096
|
|
145
141
|
|| !BASE64_RE.test(signature.publicKeySpkiDerBase64)
|
|
@@ -150,7 +146,17 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
150
146
|
|| !SHA256_RE.test(signature.keyIdSha256)) {
|
|
151
147
|
throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
|
|
152
148
|
}
|
|
153
|
-
|
|
149
|
+
let canonicalization;
|
|
150
|
+
try {
|
|
151
|
+
canonicalization = resolveArtifactCanonicalization(value.version, signature.canonicalization, [
|
|
152
|
+
{ version: LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
|
|
153
|
+
{ version: SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
|
|
154
|
+
], 'Signed evidence package');
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
|
|
158
|
+
}
|
|
159
|
+
const signedAt = canonicalTimestamp(value.signedAt);
|
|
154
160
|
const publicDer = Buffer.from(signature.publicKeySpkiDerBase64, 'base64');
|
|
155
161
|
if (keyId(publicDer) !== signature.keyIdSha256) {
|
|
156
162
|
throw new TypeError('Signed evidence package public-key identifier does not match its embedded key.');
|
|
@@ -158,16 +164,20 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
158
164
|
const embeddedPublicKey = importPublicKey(publicDer, 'Embedded evidence-signing public key');
|
|
159
165
|
const payload = {
|
|
160
166
|
schema: SIGNED_EVIDENCE_PACKAGE_SCHEMA,
|
|
161
|
-
version:
|
|
167
|
+
version: value.version,
|
|
162
168
|
signedAt,
|
|
163
169
|
artifact,
|
|
164
170
|
};
|
|
165
171
|
const signatureBytes = Buffer.from(signature.valueBase64, 'base64');
|
|
166
172
|
if (signatureBytes.length !== 64
|
|
167
|
-
|| !verify(null, payloadBytes(payload), embeddedPublicKey, signatureBytes)) {
|
|
173
|
+
|| !verify(null, payloadBytes(payload, canonicalization), embeddedPublicKey, signatureBytes)) {
|
|
168
174
|
throw new TypeError('Signed evidence package failed Ed25519 verification.');
|
|
169
175
|
}
|
|
170
|
-
|
|
176
|
+
let artifactVerification = null;
|
|
177
|
+
try {
|
|
178
|
+
artifactVerification = await verifyOfflineArtifact(JSON.stringify(artifact));
|
|
179
|
+
}
|
|
180
|
+
catch { /* Signature validity remains independently reportable. */ }
|
|
171
181
|
let publicKeyMatched = null;
|
|
172
182
|
if (trustedPublicKeyPem) {
|
|
173
183
|
const trustedDer = spkiDer(importPublicKey(trustedPublicKeyPem, 'Trusted public key file'));
|
|
@@ -176,24 +186,41 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
176
186
|
throw new TypeError('Signed evidence package does not match the trusted public key.');
|
|
177
187
|
}
|
|
178
188
|
return Object.freeze({
|
|
179
|
-
schema:
|
|
180
|
-
version:
|
|
181
|
-
valid: true,
|
|
189
|
+
schema: EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA,
|
|
190
|
+
version: EVIDENCE_SIGNATURE_VERIFICATION_VERSION,
|
|
182
191
|
state: 'signature_valid',
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
signature: Object.freeze({
|
|
193
|
+
state: 'valid',
|
|
194
|
+
signerTrust: trustedPublicKeyPem ? 'trusted_key' : 'embedded_key_only',
|
|
195
|
+
signedAt,
|
|
196
|
+
keyIdSha256: signature.keyIdSha256,
|
|
197
|
+
publicKeyMatched,
|
|
198
|
+
}),
|
|
187
199
|
artifact: Object.freeze({
|
|
188
|
-
schema: artifactVerification
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
schema: artifactVerification?.artifact.schema
|
|
201
|
+
?? (typeof artifact.schema === 'string' && /^[a-z0-9.-]{1,160}$/u.test(artifact.schema) ? artifact.schema : null),
|
|
202
|
+
version: artifactVerification?.artifact.version
|
|
203
|
+
?? (Number.isSafeInteger(artifact.version ?? artifact.schemaVersion)
|
|
204
|
+
&& Number(artifact.version ?? artifact.schemaVersion) >= 1
|
|
205
|
+
&& Number(artifact.version ?? artifact.schemaVersion) <= 1_000
|
|
206
|
+
? Number(artifact.version ?? artifact.schemaVersion)
|
|
207
|
+
: null),
|
|
208
|
+
kind: artifactVerification?.artifact.kind ?? 'unverified_artifact',
|
|
209
|
+
assurance: Object.freeze({
|
|
210
|
+
state: artifactVerification?.state ?? 'not_verified',
|
|
211
|
+
structure: artifactVerification?.checks.structure ?? 'not_checked',
|
|
212
|
+
contentIntegrity: artifactVerification?.checks.contentIntegrity ?? 'not_checked',
|
|
213
|
+
contentIntegrityScope: artifactVerification?.checks.contentIntegrityScope ?? 'not_checked',
|
|
214
|
+
}),
|
|
191
215
|
}),
|
|
192
216
|
limitations: Object.freeze([
|
|
193
217
|
trustedPublicKeyPem
|
|
194
218
|
? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
|
|
195
219
|
: 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
|
|
196
220
|
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
|
|
221
|
+
...(artifactVerification
|
|
222
|
+
? [`Embedded-artifact assurance is reported separately as ${artifactVerification.state}; it does not change the cryptographic signature result.`]
|
|
223
|
+
: ['The embedded artefact did not pass a supported local assurance contract. The cryptographic signature remains valid for its bytes, but no structure or inner-integrity claim is made.']),
|
|
197
224
|
'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
225
|
'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
|
|
199
226
|
]),
|
|
@@ -203,11 +230,12 @@ export function formatEvidenceSignatureVerification(report) {
|
|
|
203
230
|
const lines = [
|
|
204
231
|
'WHOISleuth evidence signature verification',
|
|
205
232
|
`State: ${report.state}`,
|
|
206
|
-
`Signer trust: ${report.signerTrust}`,
|
|
207
|
-
`Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
|
|
208
|
-
`
|
|
209
|
-
`
|
|
210
|
-
`
|
|
233
|
+
`Signer trust: ${report.signature.signerTrust}`,
|
|
234
|
+
`Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'unrecognised'} v${report.artifact.version ?? 'unrecognised'}`,
|
|
235
|
+
`Artifact assurance: ${report.artifact.assurance.state}`,
|
|
236
|
+
`Signed: ${report.signature.signedAt}`,
|
|
237
|
+
`Key ID: sha256:${report.signature.keyIdSha256}`,
|
|
238
|
+
`Trusted public key: ${report.signature.publicKeyMatched === null ? 'not supplied' : 'matched'}`,
|
|
211
239
|
];
|
|
212
240
|
for (const limitation of report.limitations)
|
|
213
241
|
lines.push(`Limitation: ${limitation}`);
|
package/cli/export-evidence.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import { projectCliLookupComparisonInput } from "./compare.mjs";
|
|
3
2
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { validateLookupEvidenceArtifactStructure } from "./artifact-structure.mjs";
|
|
4
|
+
import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
|
|
5
|
+
import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, serializeLookupEvidence, } from "../lib/evidence-export.mjs";
|
|
6
|
+
const APPLICATION_VERSION = WHOISLEUTH_APPLICATION_VERSION;
|
|
6
7
|
function objectOrNull(value) {
|
|
7
8
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
8
9
|
}
|
|
@@ -27,9 +28,15 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
27
28
|
|| result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
28
29
|
throw new TypeError('Lookup evidence builder returned an unsupported report contract.');
|
|
29
30
|
}
|
|
31
|
+
validateLookupEvidenceArtifactStructure(result);
|
|
32
|
+
serializeLookupEvidence(result);
|
|
30
33
|
return result;
|
|
31
34
|
}
|
|
32
35
|
function formatCliEvidenceExport(document, compact = false) {
|
|
33
|
-
|
|
36
|
+
const output = `${serializeLookupEvidence(document, !compact)}\n`;
|
|
37
|
+
if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
|
|
38
|
+
throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
34
41
|
}
|
|
35
42
|
export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -26,8 +26,8 @@ function renderRegistrarComparison(comparison) {
|
|
|
26
26
|
: '';
|
|
27
27
|
return `<div class="table-scroll"><table><caption>Normalized registry and registrar RDAP publication comparison</caption><thead><tr><th scope="col">Field</th><th scope="col">Result</th><th scope="col">Registry RDAP</th><th scope="col">Registrar RDAP</th></tr></thead><tbody>${rows}</tbody></table></div>${omitted}`;
|
|
28
28
|
}
|
|
29
|
-
function formatLookupEvidenceHtml(document) {
|
|
30
|
-
const report = buildLookupEvidenceReport(document);
|
|
29
|
+
function formatLookupEvidenceHtml(document, options = {}) {
|
|
30
|
+
const report = buildLookupEvidenceReport(document, options);
|
|
31
31
|
const title = `Lookup evidence report: ${report.title}`;
|
|
32
32
|
return `<!doctype html>
|
|
33
33
|
<html lang="en">
|
|
@@ -60,7 +60,7 @@ function formatLookupEvidenceHtml(document) {
|
|
|
60
60
|
<section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
|
|
61
61
|
<section class="block"><h2>Collection diagnostics</h2>${renderFields(report.diagnostics)}</section>
|
|
62
62
|
<section class="block"><h2>Limitations</h2><ul>${report.limitations.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></section>
|
|
63
|
-
<footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included
|
|
63
|
+
<footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included.${report.attribution ? `<br>${escapeHtml(report.attribution)}` : ''}</footer>
|
|
64
64
|
</main>
|
|
65
65
|
</body>
|
|
66
66
|
</html>
|