@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
package/cli/errors.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MAX_CLI_ERROR_MESSAGE_LENGTH = 300;
|
|
2
|
+
class CliUsageError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'CliUsageError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function errorMessage(error) {
|
|
9
|
+
if (error && typeof error === 'object' && 'message' in error)
|
|
10
|
+
return error.message;
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
function boundedCliErrorMessage(error, fallback = 'Unexpected command failure') {
|
|
14
|
+
return String(errorMessage(error) || error || fallback)
|
|
15
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ')
|
|
16
|
+
.replace(/[\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, MAX_CLI_ERROR_MESSAGE_LENGTH) || fallback;
|
|
20
|
+
}
|
|
21
|
+
export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { CliUsageError } from "./errors.mjs";
|
|
2
|
+
export function parseInspectArchiveArguments(argv) {
|
|
3
|
+
let source = null;
|
|
4
|
+
let passphraseSource = null;
|
|
5
|
+
let search = null;
|
|
6
|
+
let reveal = false;
|
|
7
|
+
let requireMatch = false;
|
|
8
|
+
let output = 'terminal';
|
|
9
|
+
let quiet = false;
|
|
10
|
+
let color = true;
|
|
11
|
+
for (let index = 0; index < argv.length; index++) {
|
|
12
|
+
const argument = argv[index];
|
|
13
|
+
if (argument === undefined)
|
|
14
|
+
break;
|
|
15
|
+
if (argument === '--json') {
|
|
16
|
+
if (output !== 'terminal')
|
|
17
|
+
throw new CliUsageError('--json may be supplied only once.');
|
|
18
|
+
output = 'json';
|
|
19
|
+
}
|
|
20
|
+
else if (argument === '--passphrase-file') {
|
|
21
|
+
if (passphraseSource !== null)
|
|
22
|
+
throw new CliUsageError('--passphrase-file may be supplied only once.');
|
|
23
|
+
const value = argv[++index];
|
|
24
|
+
if (!value || value.startsWith('-'))
|
|
25
|
+
throw new CliUsageError('--passphrase-file requires one bounded UTF-8 file.');
|
|
26
|
+
passphraseSource = value;
|
|
27
|
+
}
|
|
28
|
+
else if (argument === '--search') {
|
|
29
|
+
if (search !== null)
|
|
30
|
+
throw new CliUsageError('--search may be supplied only once.');
|
|
31
|
+
const value = argv[++index];
|
|
32
|
+
if (!value || value.startsWith('-'))
|
|
33
|
+
throw new CliUsageError('--search requires one exact bounded value.');
|
|
34
|
+
search = value;
|
|
35
|
+
}
|
|
36
|
+
else if (argument === '--reveal') {
|
|
37
|
+
if (reveal)
|
|
38
|
+
throw new CliUsageError('--reveal may be supplied only once.');
|
|
39
|
+
reveal = true;
|
|
40
|
+
}
|
|
41
|
+
else if (argument === '--require-match') {
|
|
42
|
+
if (requireMatch)
|
|
43
|
+
throw new CliUsageError('--require-match may be supplied only once.');
|
|
44
|
+
requireMatch = true;
|
|
45
|
+
}
|
|
46
|
+
else if (argument === '--quiet')
|
|
47
|
+
quiet = true;
|
|
48
|
+
else if (argument === '--no-color')
|
|
49
|
+
color = false;
|
|
50
|
+
else if (argument.startsWith('-'))
|
|
51
|
+
throw new CliUsageError(`Unknown option "${argument}".`);
|
|
52
|
+
else if (source === null)
|
|
53
|
+
source = argument;
|
|
54
|
+
else
|
|
55
|
+
throw new CliUsageError('inspect-archive accepts one optional JSON file. Otherwise pipe one archive on stdin.');
|
|
56
|
+
}
|
|
57
|
+
if (reveal && search === null)
|
|
58
|
+
throw new CliUsageError('--reveal requires --search.');
|
|
59
|
+
if (requireMatch && search === null)
|
|
60
|
+
throw new CliUsageError('--require-match requires --search.');
|
|
61
|
+
if (quiet && output !== 'terminal')
|
|
62
|
+
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
63
|
+
return {
|
|
64
|
+
action: 'inspect-archive',
|
|
65
|
+
source,
|
|
66
|
+
passphraseSource,
|
|
67
|
+
search,
|
|
68
|
+
reveal,
|
|
69
|
+
requireMatch,
|
|
70
|
+
output,
|
|
71
|
+
quiet,
|
|
72
|
+
color,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function parseSignArtifactArguments(argv) {
|
|
76
|
+
let source = null;
|
|
77
|
+
let privateKeySource = null;
|
|
78
|
+
for (let index = 0; index < argv.length; index++) {
|
|
79
|
+
const argument = argv[index];
|
|
80
|
+
if (argument === undefined)
|
|
81
|
+
break;
|
|
82
|
+
if (argument === '--private-key-file') {
|
|
83
|
+
if (privateKeySource !== null)
|
|
84
|
+
throw new CliUsageError('--private-key-file may be supplied only once.');
|
|
85
|
+
const value = argv[++index];
|
|
86
|
+
if (!value || value.startsWith('-'))
|
|
87
|
+
throw new CliUsageError('--private-key-file requires one bounded PEM file.');
|
|
88
|
+
privateKeySource = value;
|
|
89
|
+
}
|
|
90
|
+
else if (argument.startsWith('-'))
|
|
91
|
+
throw new CliUsageError(`Unknown option "${argument}".`);
|
|
92
|
+
else if (source === null)
|
|
93
|
+
source = argument;
|
|
94
|
+
else
|
|
95
|
+
throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one artifact on stdin.');
|
|
96
|
+
}
|
|
97
|
+
if (!privateKeySource)
|
|
98
|
+
throw new CliUsageError('sign-artifact requires --private-key-file.');
|
|
99
|
+
return { action: 'sign-artifact', source, privateKeySource };
|
|
100
|
+
}
|
|
101
|
+
export function parseVerifySignatureArguments(argv) {
|
|
102
|
+
let source = null;
|
|
103
|
+
let publicKeySource = null;
|
|
104
|
+
let output = 'terminal';
|
|
105
|
+
let quiet = false;
|
|
106
|
+
let color = true;
|
|
107
|
+
for (let index = 0; index < argv.length; index++) {
|
|
108
|
+
const argument = argv[index];
|
|
109
|
+
if (argument === undefined)
|
|
110
|
+
break;
|
|
111
|
+
if (argument === '--json') {
|
|
112
|
+
if (output !== 'terminal')
|
|
113
|
+
throw new CliUsageError('--json may be supplied only once.');
|
|
114
|
+
output = 'json';
|
|
115
|
+
}
|
|
116
|
+
else if (argument === '--public-key-file') {
|
|
117
|
+
if (publicKeySource !== null)
|
|
118
|
+
throw new CliUsageError('--public-key-file may be supplied only once.');
|
|
119
|
+
const value = argv[++index];
|
|
120
|
+
if (!value || value.startsWith('-'))
|
|
121
|
+
throw new CliUsageError('--public-key-file requires one bounded PEM file.');
|
|
122
|
+
publicKeySource = value;
|
|
123
|
+
}
|
|
124
|
+
else if (argument === '--quiet')
|
|
125
|
+
quiet = true;
|
|
126
|
+
else if (argument === '--no-color')
|
|
127
|
+
color = false;
|
|
128
|
+
else if (argument.startsWith('-'))
|
|
129
|
+
throw new CliUsageError(`Unknown option "${argument}".`);
|
|
130
|
+
else if (source === null)
|
|
131
|
+
source = argument;
|
|
132
|
+
else
|
|
133
|
+
throw new CliUsageError('verify-signature accepts one optional JSON file. Otherwise pipe one package on stdin.');
|
|
134
|
+
}
|
|
135
|
+
if (quiet && output !== 'terminal')
|
|
136
|
+
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
137
|
+
return {
|
|
138
|
+
action: 'verify-signature',
|
|
139
|
+
source,
|
|
140
|
+
publicKeySource,
|
|
141
|
+
output,
|
|
142
|
+
quiet,
|
|
143
|
+
color,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
|
|
3
|
+
import { formatArchiveInspection, inspectWorkspaceArchive, } from "./archive-inspect.mjs";
|
|
4
|
+
import { MAX_SIGNING_KEY_FILE_BYTES, formatEvidenceSignatureVerification, signEvidencePackage, verifyEvidencePackageSignature, } from "./evidence-signing.mjs";
|
|
5
|
+
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
6
|
+
import EXIT_CODES from "./exit-codes.mjs";
|
|
7
|
+
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
8
|
+
import { readSavedLookupInputBounded, } from "./saved-lookup.mjs";
|
|
9
|
+
export function isEvidenceCommand(args) {
|
|
10
|
+
return args.action === 'inspect-archive'
|
|
11
|
+
|| args.action === 'sign-artifact'
|
|
12
|
+
|| args.action === 'verify-signature';
|
|
13
|
+
}
|
|
14
|
+
export function evidenceCommandFailureLabel(action) {
|
|
15
|
+
if (action === 'inspect-archive')
|
|
16
|
+
return 'Archive inspection';
|
|
17
|
+
if (action === 'sign-artifact')
|
|
18
|
+
return 'Evidence signing';
|
|
19
|
+
return 'Evidence signature verification';
|
|
20
|
+
}
|
|
21
|
+
async function readArtifact(source, label, dependencies) {
|
|
22
|
+
try {
|
|
23
|
+
return dependencies.readArtifactInput
|
|
24
|
+
? await dependencies.readArtifactInput(source)
|
|
25
|
+
: await readSavedLookupInputBounded(source
|
|
26
|
+
? createReadStream(source, { highWaterMark: 64 * 1024 })
|
|
27
|
+
: dependencies.stdin, {
|
|
28
|
+
limit: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
29
|
+
label,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof CliUsageError)
|
|
34
|
+
throw error;
|
|
35
|
+
throw new CliUsageError(`Could not read ${label.toLowerCase()}: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function readKey(source, label, injected) {
|
|
39
|
+
try {
|
|
40
|
+
return injected
|
|
41
|
+
? await injected(source)
|
|
42
|
+
: await readSavedLookupInputBounded(createReadStream(source, { highWaterMark: MAX_SIGNING_KEY_FILE_BYTES }), { limit: MAX_SIGNING_KEY_FILE_BYTES, label });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof CliUsageError)
|
|
46
|
+
throw error;
|
|
47
|
+
throw new CliUsageError(`Could not read ${label.toLowerCase()}: ${boundedCliErrorMessage(error, 'File could not be read')}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function readPassphrase(source, dependencies) {
|
|
51
|
+
const passphraseText = dependencies.readPassphraseFile
|
|
52
|
+
? await dependencies.readPassphraseFile(source)
|
|
53
|
+
: await readSavedLookupInputBounded(createReadStream(source, { highWaterMark: MAX_OFFLINE_PASSPHRASE_FILE_BYTES }), { limit: MAX_OFFLINE_PASSPHRASE_FILE_BYTES, label: 'Passphrase file' });
|
|
54
|
+
const passphrase = passphraseText.replace(/\r?\n$/u, '');
|
|
55
|
+
if (!passphrase || /[\r\n\u0000]/u.test(passphrase)) {
|
|
56
|
+
throw new CliUsageError('Passphrase file must contain exactly one non-empty UTF-8 line.');
|
|
57
|
+
}
|
|
58
|
+
return passphrase;
|
|
59
|
+
}
|
|
60
|
+
export async function runEvidenceCommand(args, dependencies) {
|
|
61
|
+
if (args.action === 'inspect-archive') {
|
|
62
|
+
const input = await readArtifact(args.source, 'Archive input', dependencies);
|
|
63
|
+
if (!input.trim()) {
|
|
64
|
+
throw new CliUsageError('inspect-archive requires one workspace archive file or an archive on stdin.');
|
|
65
|
+
}
|
|
66
|
+
const passphrase = args.passphraseSource
|
|
67
|
+
? await readPassphrase(args.passphraseSource, dependencies)
|
|
68
|
+
: null;
|
|
69
|
+
const report = await inspectWorkspaceArchive(input, {
|
|
70
|
+
passphrase,
|
|
71
|
+
search: args.search,
|
|
72
|
+
reveal: args.reveal,
|
|
73
|
+
requireMatch: args.requireMatch,
|
|
74
|
+
});
|
|
75
|
+
if (!args.quiet) {
|
|
76
|
+
dependencies.stdout.write(args.output === 'json'
|
|
77
|
+
? formatJsonDocument(report)
|
|
78
|
+
: formatArchiveInspection(report));
|
|
79
|
+
}
|
|
80
|
+
return EXIT_CODES.SUCCESS;
|
|
81
|
+
}
|
|
82
|
+
if (args.action === 'sign-artifact') {
|
|
83
|
+
const input = await readArtifact(args.source, 'Evidence artifact input', dependencies);
|
|
84
|
+
if (!input.trim()) {
|
|
85
|
+
throw new CliUsageError('sign-artifact requires one reviewed artifact file or an artifact on stdin.');
|
|
86
|
+
}
|
|
87
|
+
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
|
|
88
|
+
const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
|
|
89
|
+
dependencies.stdout.write(formatJsonDocument(signed));
|
|
90
|
+
return EXIT_CODES.SUCCESS;
|
|
91
|
+
}
|
|
92
|
+
const input = await readArtifact(args.source, 'Signed evidence package input', dependencies);
|
|
93
|
+
if (!input.trim()) {
|
|
94
|
+
throw new CliUsageError('verify-signature requires one signed package file or a package on stdin.');
|
|
95
|
+
}
|
|
96
|
+
const publicKey = args.publicKeySource
|
|
97
|
+
? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile)
|
|
98
|
+
: null;
|
|
99
|
+
const report = await verifyEvidencePackageSignature(input, publicKey);
|
|
100
|
+
if (!args.quiet) {
|
|
101
|
+
dependencies.stdout.write(args.output === 'json'
|
|
102
|
+
? formatJsonDocument(report)
|
|
103
|
+
: formatEvidenceSignatureVerification(report));
|
|
104
|
+
}
|
|
105
|
+
return EXIT_CODES.SUCCESS;
|
|
106
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
|
|
3
|
+
import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
5
|
+
export const SIGNED_EVIDENCE_PACKAGE_SCHEMA = 'whoisleuth.signed-evidence-package';
|
|
6
|
+
export const SIGNED_EVIDENCE_PACKAGE_VERSION = 1;
|
|
7
|
+
export const EVIDENCE_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
8
|
+
export const EVIDENCE_SIGNATURE_CANONICALIZATION = 'sorted-json-v1';
|
|
9
|
+
export const MAX_SIGNING_KEY_FILE_BYTES = 16 * 1024;
|
|
10
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
11
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
12
|
+
const PACKAGE_KEYS = new Set(['schema', 'version', 'signedAt', 'artifact', 'signature']);
|
|
13
|
+
const SIGNATURE_KEYS = new Set([
|
|
14
|
+
'algorithm',
|
|
15
|
+
'canonicalization',
|
|
16
|
+
'publicKeySpkiDerBase64',
|
|
17
|
+
'keyIdSha256',
|
|
18
|
+
'valueBase64',
|
|
19
|
+
]);
|
|
20
|
+
function record(value) {
|
|
21
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
22
|
+
? value
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
function parseObject(raw, label) {
|
|
26
|
+
if (typeof raw !== 'string')
|
|
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;
|
|
43
|
+
}
|
|
44
|
+
function hasExactKeys(value, expected) {
|
|
45
|
+
const keys = Object.keys(value);
|
|
46
|
+
return keys.length === expected.size && keys.every((key) => expected.has(key));
|
|
47
|
+
}
|
|
48
|
+
function timestamp(value) {
|
|
49
|
+
if (typeof value !== 'string' || value.length > 64) {
|
|
50
|
+
throw new TypeError('Signature time must be a valid timestamp.');
|
|
51
|
+
}
|
|
52
|
+
const parsed = Date.parse(value);
|
|
53
|
+
if (!Number.isFinite(parsed))
|
|
54
|
+
throw new TypeError('Signature time must be a valid timestamp.');
|
|
55
|
+
return new Date(parsed).toISOString();
|
|
56
|
+
}
|
|
57
|
+
function importPrivateKey(pem) {
|
|
58
|
+
if (Buffer.byteLength(pem, 'utf8') > MAX_SIGNING_KEY_FILE_BYTES) {
|
|
59
|
+
throw new TypeError(`Private key file exceeds ${MAX_SIGNING_KEY_FILE_BYTES} bytes.`);
|
|
60
|
+
}
|
|
61
|
+
let key;
|
|
62
|
+
try {
|
|
63
|
+
key = createPrivateKey(pem);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new TypeError('Private key file must contain one readable PEM private key.');
|
|
67
|
+
}
|
|
68
|
+
if (key.asymmetricKeyType !== 'ed25519') {
|
|
69
|
+
throw new TypeError('Evidence signing requires an Ed25519 private key.');
|
|
70
|
+
}
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
function importPublicKey(value, label) {
|
|
74
|
+
if (Buffer.byteLength(value) > MAX_SIGNING_KEY_FILE_BYTES) {
|
|
75
|
+
throw new TypeError(`${label} exceeds ${MAX_SIGNING_KEY_FILE_BYTES} bytes.`);
|
|
76
|
+
}
|
|
77
|
+
let key;
|
|
78
|
+
try {
|
|
79
|
+
key = Buffer.isBuffer(value)
|
|
80
|
+
? createPublicKey({ key: value, format: 'der', type: 'spki' })
|
|
81
|
+
: createPublicKey(value);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new TypeError(`${label} must contain one readable Ed25519 public key.`);
|
|
85
|
+
}
|
|
86
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
87
|
+
throw new TypeError(`${label} must be an Ed25519 public key.`);
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
function spkiDer(key) {
|
|
91
|
+
const publicKey = key.type === 'public'
|
|
92
|
+
? key
|
|
93
|
+
: createPublicKey(key.export({ type: 'pkcs8', format: 'pem' }));
|
|
94
|
+
return publicKey.export({ type: 'spki', format: 'der' });
|
|
95
|
+
}
|
|
96
|
+
function keyId(publicDer) {
|
|
97
|
+
return createHash('sha256').update(publicDer).digest('hex');
|
|
98
|
+
}
|
|
99
|
+
function payloadBytes(payload) {
|
|
100
|
+
return Buffer.from(canonicalArtifactJson(payload), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
async function signableArtifact(raw) {
|
|
103
|
+
const artifact = parseObject(raw, 'Evidence artifact');
|
|
104
|
+
const verification = await verifyOfflineArtifact(raw);
|
|
105
|
+
if (!['case_response_packet', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
|
+
throw new TypeError('Only reviewed response packets and supported review manifests can be signed.');
|
|
107
|
+
}
|
|
108
|
+
return { artifact, verification };
|
|
109
|
+
}
|
|
110
|
+
export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Date().toISOString()) {
|
|
111
|
+
const { artifact } = await signableArtifact(raw);
|
|
112
|
+
const privateKey = importPrivateKey(privateKeyPem);
|
|
113
|
+
const publicDer = spkiDer(privateKey);
|
|
114
|
+
const payload = Object.freeze({
|
|
115
|
+
schema: SIGNED_EVIDENCE_PACKAGE_SCHEMA,
|
|
116
|
+
version: SIGNED_EVIDENCE_PACKAGE_VERSION,
|
|
117
|
+
signedAt: timestamp(signedAt),
|
|
118
|
+
artifact,
|
|
119
|
+
});
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
...payload,
|
|
122
|
+
signature: Object.freeze({
|
|
123
|
+
algorithm: EVIDENCE_SIGNATURE_ALGORITHM,
|
|
124
|
+
canonicalization: EVIDENCE_SIGNATURE_CANONICALIZATION,
|
|
125
|
+
publicKeySpkiDerBase64: publicDer.toString('base64'),
|
|
126
|
+
keyIdSha256: keyId(publicDer),
|
|
127
|
+
valueBase64: sign(null, payloadBytes(payload), privateKey).toString('base64'),
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
132
|
+
const value = parseObject(raw, 'Signed evidence package');
|
|
133
|
+
const signature = record(value.signature);
|
|
134
|
+
const artifact = record(value.artifact);
|
|
135
|
+
if (!hasExactKeys(value, PACKAGE_KEYS)
|
|
136
|
+
|| value.schema !== SIGNED_EVIDENCE_PACKAGE_SCHEMA
|
|
137
|
+
|| value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION
|
|
138
|
+
|| !artifact
|
|
139
|
+
|| !signature
|
|
140
|
+
|| !hasExactKeys(signature, SIGNATURE_KEYS)
|
|
141
|
+
|| signature.algorithm !== EVIDENCE_SIGNATURE_ALGORITHM
|
|
142
|
+
|| signature.canonicalization !== EVIDENCE_SIGNATURE_CANONICALIZATION
|
|
143
|
+
|| typeof signature.publicKeySpkiDerBase64 !== 'string'
|
|
144
|
+
|| signature.publicKeySpkiDerBase64.length > 4096
|
|
145
|
+
|| !BASE64_RE.test(signature.publicKeySpkiDerBase64)
|
|
146
|
+
|| typeof signature.valueBase64 !== 'string'
|
|
147
|
+
|| signature.valueBase64.length > 4096
|
|
148
|
+
|| !BASE64_RE.test(signature.valueBase64)
|
|
149
|
+
|| typeof signature.keyIdSha256 !== 'string'
|
|
150
|
+
|| !SHA256_RE.test(signature.keyIdSha256)) {
|
|
151
|
+
throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
|
|
152
|
+
}
|
|
153
|
+
const signedAt = timestamp(value.signedAt);
|
|
154
|
+
const publicDer = Buffer.from(signature.publicKeySpkiDerBase64, 'base64');
|
|
155
|
+
if (keyId(publicDer) !== signature.keyIdSha256) {
|
|
156
|
+
throw new TypeError('Signed evidence package public-key identifier does not match its embedded key.');
|
|
157
|
+
}
|
|
158
|
+
const embeddedPublicKey = importPublicKey(publicDer, 'Embedded evidence-signing public key');
|
|
159
|
+
const payload = {
|
|
160
|
+
schema: SIGNED_EVIDENCE_PACKAGE_SCHEMA,
|
|
161
|
+
version: SIGNED_EVIDENCE_PACKAGE_VERSION,
|
|
162
|
+
signedAt,
|
|
163
|
+
artifact,
|
|
164
|
+
};
|
|
165
|
+
const signatureBytes = Buffer.from(signature.valueBase64, 'base64');
|
|
166
|
+
if (signatureBytes.length !== 64
|
|
167
|
+
|| !verify(null, payloadBytes(payload), embeddedPublicKey, signatureBytes)) {
|
|
168
|
+
throw new TypeError('Signed evidence package failed Ed25519 verification.');
|
|
169
|
+
}
|
|
170
|
+
const artifactVerification = await verifyOfflineArtifact(JSON.stringify(artifact));
|
|
171
|
+
let publicKeyMatched = null;
|
|
172
|
+
if (trustedPublicKeyPem) {
|
|
173
|
+
const trustedDer = spkiDer(importPublicKey(trustedPublicKeyPem, 'Trusted public key file'));
|
|
174
|
+
publicKeyMatched = trustedDer.equals(publicDer);
|
|
175
|
+
if (!publicKeyMatched)
|
|
176
|
+
throw new TypeError('Signed evidence package does not match the trusted public key.');
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
schema: 'whoisleuth.evidence-signature-verification',
|
|
180
|
+
version: 1,
|
|
181
|
+
valid: true,
|
|
182
|
+
state: 'signature_valid',
|
|
183
|
+
signerTrust: trustedPublicKeyPem ? 'trusted_key' : 'embedded_key_only',
|
|
184
|
+
signedAt,
|
|
185
|
+
keyIdSha256: signature.keyIdSha256,
|
|
186
|
+
publicKeyMatched,
|
|
187
|
+
artifact: Object.freeze({
|
|
188
|
+
schema: artifactVerification.artifact.schema,
|
|
189
|
+
version: artifactVerification.artifact.version,
|
|
190
|
+
kind: artifactVerification.artifact.kind,
|
|
191
|
+
}),
|
|
192
|
+
limitations: Object.freeze([
|
|
193
|
+
trustedPublicKeyPem
|
|
194
|
+
? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
|
|
195
|
+
: 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
|
|
196
|
+
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialized file.',
|
|
197
|
+
'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
|
|
198
|
+
'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
|
|
199
|
+
]),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export function formatEvidenceSignatureVerification(report) {
|
|
203
|
+
const lines = [
|
|
204
|
+
'WHOISleuth evidence signature verification',
|
|
205
|
+
`State: ${report.state}`,
|
|
206
|
+
`Signer trust: ${report.signerTrust}`,
|
|
207
|
+
`Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
|
|
208
|
+
`Signed: ${report.signedAt}`,
|
|
209
|
+
`Key ID: sha256:${report.keyIdSha256}`,
|
|
210
|
+
`Trusted public key: ${report.publicKeyMatched === null ? 'not supplied' : 'matched'}`,
|
|
211
|
+
];
|
|
212
|
+
for (const limitation of report.limitations)
|
|
213
|
+
lines.push(`Limitation: ${limitation}`);
|
|
214
|
+
return `${lines.join('\n')}\n`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { projectCliLookupComparisonInput } from "./compare.mjs";
|
|
2
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
3
|
+
function objectOrNull(value) {
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
}
|
|
6
|
+
function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().toISOString()) {
|
|
7
|
+
const source = parseSavedLookupDocument(text, { label: 'Evidence export input' });
|
|
8
|
+
// Revalidate every normalized field consumed by the shared comparison
|
|
9
|
+
// model. Raw source payloads are retained deliberately, but remain bounded
|
|
10
|
+
// by the saved-document byte ceiling and are never interpreted here.
|
|
11
|
+
projectCliLookupComparisonInput(source);
|
|
12
|
+
const dependency = objectOrNull(evidenceModule);
|
|
13
|
+
if (!dependency || typeof dependency.buildLookupEvidence !== 'function') {
|
|
14
|
+
throw new TypeError('Lookup evidence export dependency is required.');
|
|
15
|
+
}
|
|
16
|
+
const buildLookupEvidence = dependency.buildLookupEvidence;
|
|
17
|
+
const result = objectOrNull(buildLookupEvidence(source, { generatedAt, idnAnalysis: null }));
|
|
18
|
+
if (!result
|
|
19
|
+
|| result.schema !== dependency.LOOKUP_EVIDENCE_SCHEMA
|
|
20
|
+
|| result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
21
|
+
throw new TypeError('Lookup evidence builder returned an unsupported report contract.');
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
function formatCliEvidenceExport(document, compact = false) {
|
|
26
|
+
return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
|
|
27
|
+
}
|
|
28
|
+
export { buildCliEvidenceExport, formatCliEvidenceExport };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { buildLookupEvidenceReport, cleanReportText } from "./evidence-report.mjs";
|
|
2
|
+
function escapeHtml(value, fallback = 'Not reported') {
|
|
3
|
+
return cleanReportText(value, fallback)
|
|
4
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
6
|
+
}
|
|
7
|
+
function renderFields(fields) {
|
|
8
|
+
return `<dl class="fields">${fields.map((field) => `<div><dt>${escapeHtml(field.label)}</dt><dd>${escapeHtml(field.value)}</dd></div>`).join('')}</dl>`;
|
|
9
|
+
}
|
|
10
|
+
function renderGroups(groups) {
|
|
11
|
+
return `<div class="card-grid">${groups.map((group) => `<section class="card"><h3>${escapeHtml(group.title)}</h3>${renderFields(group.fields)}</section>`).join('')}</div>`;
|
|
12
|
+
}
|
|
13
|
+
function renderComparison(comparison) {
|
|
14
|
+
if (!comparison.fields.length)
|
|
15
|
+
return '<p class="empty">No comparable normalized fields were published.</p>';
|
|
16
|
+
const rows = comparison.fields.map((field) => `<tr><th scope="row">${escapeHtml(field.label)}</th><td><span class="status">${escapeHtml(field.status)}</span></td><td>${escapeHtml(field.rdap)}</td><td>${escapeHtml(field.whois)}</td></tr>`).join('');
|
|
17
|
+
const omitted = comparison.omitted
|
|
18
|
+
? `<p class="omission">${escapeHtml(comparison.omitted)} additional comparison fields omitted.</p>`
|
|
19
|
+
: '';
|
|
20
|
+
return `<div class="table-scroll"><table><caption>Normalized registry publication comparison</caption><thead><tr><th scope="col">Field</th><th scope="col">Result</th><th scope="col">RDAP</th><th scope="col">WHOIS</th></tr></thead><tbody>${rows}</tbody></table></div>${omitted}`;
|
|
21
|
+
}
|
|
22
|
+
function renderRegistrarComparison(comparison) {
|
|
23
|
+
const rows = comparison.fields.map((field) => `<tr><th scope="row">${escapeHtml(field.label)}</th><td><span class="status">${escapeHtml(field.status)}</span></td><td>${escapeHtml(field.registry)}</td><td>${escapeHtml(field.registrar)}</td></tr>`).join('');
|
|
24
|
+
const omitted = comparison.omitted
|
|
25
|
+
? `<p class="omission">${escapeHtml(comparison.omitted)} additional comparison fields omitted.</p>`
|
|
26
|
+
: '';
|
|
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
|
+
}
|
|
29
|
+
function formatLookupEvidenceHtml(document) {
|
|
30
|
+
const report = buildLookupEvidenceReport(document);
|
|
31
|
+
const title = `Lookup evidence report — ${report.title}`;
|
|
32
|
+
return `<!doctype html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="utf-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
37
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
|
38
|
+
<meta name="referrer" content="no-referrer">
|
|
39
|
+
<title>${escapeHtml(title)}</title>
|
|
40
|
+
<style>
|
|
41
|
+
:root{color-scheme:dark;--bg:#07110d;--panel:#0d1b15;--panel2:#10241b;--line:#254837;--text:#d8f5e4;--muted:#91b7a0;--accent:#6dff9d;--shadow:rgba(0,0,0,.28)}
|
|
42
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{border:1px solid var(--line);background:linear-gradient(145deg,var(--panel2),var(--panel));padding:28px;box-shadow:0 18px 50px var(--shadow)}h1,h2,h3{line-height:1.2;margin:0;color:var(--accent)}h1{font-size:clamp(1.5rem,4vw,2.35rem);overflow-wrap:anywhere}h2{font-size:1.22rem;margin-bottom:16px}h3{font-size:1rem;margin-bottom:12px}.eyebrow{margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}.notice{margin:18px 0 0;color:var(--muted);max-width:85ch}.block{margin-top:24px;border:1px solid var(--line);background:var(--panel);padding:22px}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;margin:0}.fields div{display:grid;grid-template-columns:minmax(130px,.8fr) minmax(0,1.6fr);gap:12px;border-top:1px solid var(--line);padding:9px 0}.fields div:first-child,.fields div:nth-child(2){border-top:0}.fields dt{color:var(--muted)}.fields dd{margin:0;overflow-wrap:anywhere}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px}.card{border:1px solid var(--line);background:var(--panel2);padding:18px}.card .fields{display:block}.card .fields div{grid-template-columns:minmax(120px,.8fr) minmax(0,1.4fr)}.card .fields div:nth-child(2){border-top:1px solid var(--line)}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;min-width:720px}caption{text-align:left;color:var(--muted);padding:0 0 10px}th,td{border:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:var(--panel2);color:var(--accent)}tbody th{color:var(--text)}.status{white-space:nowrap;color:var(--accent)}.empty,.omission{color:var(--muted)}ul{margin:0;padding-left:22px}li+li{margin-top:8px}footer{margin-top:24px;color:var(--muted);font-size:.88rem;text-align:center}
|
|
43
|
+
@media(max-width:700px){main{width:min(100% - 20px,1120px);padding-top:16px}header,.block{padding:17px}.fields{display:block}.fields div,.card .fields div{grid-template-columns:1fr;gap:2px}.fields div:nth-child(2){border-top:1px solid var(--line)}}
|
|
44
|
+
@media print{:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f5f7f5;--line:#b9c5bd;--text:#17231b;--muted:#46564b;--accent:#155c2f;--shadow:transparent}body{font:10pt/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:100%;padding:0}header,.block,.card{box-shadow:none;break-inside:avoid}.block{margin-top:12px}}
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main>
|
|
49
|
+
<header>
|
|
50
|
+
<p class="eyebrow">WHOISleuth evidence</p>
|
|
51
|
+
<h1>${escapeHtml(title)}</h1>
|
|
52
|
+
<p class="notice">${escapeHtml(report.notice)}</p>
|
|
53
|
+
${renderFields(report.metadata)}
|
|
54
|
+
</header>
|
|
55
|
+
<section class="block"><h2>Query</h2>${renderFields(report.query)}</section>
|
|
56
|
+
<section class="block"><h2>Assessment</h2>${renderFields(report.assessment)}</section>
|
|
57
|
+
<section class="block"><h2>Registry sources</h2>${renderGroups(report.registryGroups)}</section>
|
|
58
|
+
<section class="block"><h2>Registry-source comparison</h2>${renderFields(report.comparison.health)}${renderComparison(report.comparison)}</section>
|
|
59
|
+
${report.registrarComparison.fields.length ? `<section class="block"><h2>Registry / registrar RDAP comparison</h2>${renderFields(report.registrarComparison.health)}${renderRegistrarComparison(report.registrarComparison)}</section>` : ''}
|
|
60
|
+
<section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
|
|
61
|
+
<section class="block"><h2>Collection diagnostics</h2>${renderFields(report.diagnostics)}</section>
|
|
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.</footer>
|
|
64
|
+
</main>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
export { escapeHtml, formatLookupEvidenceHtml };
|