@slicedearth/whoisleuth-cli 1.36.1 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- 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-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -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
|
+
}
|
|
@@ -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,
|
|
@@ -92,7 +103,7 @@ export function parseSignArtifactArguments(argv) {
|
|
|
92
103
|
else if (source === null)
|
|
93
104
|
source = argument;
|
|
94
105
|
else
|
|
95
|
-
throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one
|
|
106
|
+
throw new CliUsageError('sign-artifact accepts one optional JSON file. Otherwise pipe one artefact on stdin.');
|
|
96
107
|
}
|
|
97
108
|
if (!privateKeySource)
|
|
98
109
|
throw new CliUsageError('sign-artifact requires --private-key-file.');
|
|
@@ -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'
|
|
@@ -80,9 +81,9 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
80
81
|
return EXIT_CODES.SUCCESS;
|
|
81
82
|
}
|
|
82
83
|
if (args.action === 'sign-artifact') {
|
|
83
|
-
const input = await readArtifact(args.source, 'Evidence
|
|
84
|
+
const input = await readArtifact(args.source, 'Evidence artefact input', dependencies);
|
|
84
85
|
if (!input.trim()) {
|
|
85
|
-
throw new CliUsageError('sign-artifact requires one reviewed
|
|
86
|
+
throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
|
|
86
87
|
}
|
|
87
88
|
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
|
|
88
89
|
const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
|
package/cli/evidence-signing.mjs
CHANGED
|
@@ -100,10 +100,10 @@ function payloadBytes(payload) {
|
|
|
100
100
|
return Buffer.from(canonicalArtifactJson(payload), 'utf8');
|
|
101
101
|
}
|
|
102
102
|
async function signableArtifact(raw) {
|
|
103
|
-
const artifact = parseObject(raw, 'Evidence
|
|
103
|
+
const artifact = parseObject(raw, 'Evidence artefact');
|
|
104
104
|
const verification = await verifyOfflineArtifact(raw);
|
|
105
|
-
if (!['case_response_packet', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
|
-
throw new TypeError('Only reviewed response packets and supported review manifests can be signed.');
|
|
105
|
+
if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
|
+
throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
|
|
107
107
|
}
|
|
108
108
|
return { artifact, verification };
|
|
109
109
|
}
|
|
@@ -193,7 +193,7 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
193
193
|
trustedPublicKeyPem
|
|
194
194
|
? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
|
|
195
195
|
: 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
|
|
196
|
-
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the
|
|
196
|
+
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
|
|
197
197
|
'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
|
|
198
198
|
'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
|
|
199
199
|
]),
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { domainToASCII } from 'node:url';
|
|
2
|
+
import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
|
|
3
|
+
import { EXTERNAL_FINDING_CATEGORIES, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_DOMAINS, parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
|
|
4
|
+
export const EXTERNAL_OBSERVATION_MAPPING_SCHEMA = 'whoisleuth.external-observation-mapping';
|
|
5
|
+
export const EXTERNAL_OBSERVATION_MAPPING_VERSION = 1;
|
|
6
|
+
export const MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
export const MAX_EXTERNAL_OBSERVATION_RECORDS = 500;
|
|
8
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'source', 'profile', 'records']);
|
|
9
|
+
const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
|
|
10
|
+
const PROFILE_KEYS = new Set([
|
|
11
|
+
'id', 'version', 'domainField', 'summaryField', 'observedAtField', 'referenceField',
|
|
12
|
+
'completenessField', 'category', 'evidenceClass', 'limitations',
|
|
13
|
+
]);
|
|
14
|
+
const PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*){0,7}$/u;
|
|
15
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
16
|
+
const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
|
|
17
|
+
const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
|
|
18
|
+
function optionalText(value, label, maximum) {
|
|
19
|
+
if (value === undefined || value === null || value === '')
|
|
20
|
+
return null;
|
|
21
|
+
return requireBoundedString(value, label, maximum);
|
|
22
|
+
}
|
|
23
|
+
function path(value, label, optional = false) {
|
|
24
|
+
if (optional && (value === undefined || value === null || value === ''))
|
|
25
|
+
return null;
|
|
26
|
+
const candidate = requireBoundedString(value, label, 200);
|
|
27
|
+
if (!PATH_RE.test(candidate) || candidate.split('.').some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))) {
|
|
28
|
+
throw new TypeError(`${label} must be a safe bounded dotted field path.`);
|
|
29
|
+
}
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
function readPath(value, fieldPath) {
|
|
33
|
+
let current = value;
|
|
34
|
+
for (const segment of fieldPath.split('.')) {
|
|
35
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
36
|
+
return undefined;
|
|
37
|
+
current = current[segment];
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
function domain(value, label) {
|
|
42
|
+
const supplied = requireBoundedString(value, label, 253).trim().toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '');
|
|
43
|
+
const ascii = domainToASCII(supplied).toLowerCase();
|
|
44
|
+
if (!ascii || ascii.length > 253 || !ascii.includes('.') || ascii.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part))) {
|
|
45
|
+
throw new TypeError(`${label} must resolve to a valid DNS name.`);
|
|
46
|
+
}
|
|
47
|
+
return ascii;
|
|
48
|
+
}
|
|
49
|
+
function limitations(value) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return Object.freeze([]);
|
|
52
|
+
if (!Array.isArray(value) || value.length > 8)
|
|
53
|
+
throw new TypeError('profile.limitations must contain no more than 8 entries.');
|
|
54
|
+
return Object.freeze([...new Set(value.map((entry, index) => requireBoundedString(entry, `profile.limitations[${index}]`, 240)))]);
|
|
55
|
+
}
|
|
56
|
+
export function mapExternalObservations(inputRaw) {
|
|
57
|
+
const input = requireRecord(inputRaw, 'External observation mapping');
|
|
58
|
+
if (input.schema !== EXTERNAL_OBSERVATION_MAPPING_SCHEMA || input.version !== EXTERNAL_OBSERVATION_MAPPING_VERSION) {
|
|
59
|
+
throw new TypeError(`External observation mapping must use ${EXTERNAL_OBSERVATION_MAPPING_SCHEMA} version ${EXTERNAL_OBSERVATION_MAPPING_VERSION}.`);
|
|
60
|
+
}
|
|
61
|
+
exactKeys(input, ROOT_KEYS, 'External observation mapping');
|
|
62
|
+
const sourceInput = requireRecord(input.source, 'source');
|
|
63
|
+
exactKeys(sourceInput, SOURCE_KEYS, 'source');
|
|
64
|
+
const profileInput = requireRecord(input.profile, 'profile');
|
|
65
|
+
exactKeys(profileInput, PROFILE_KEYS, 'profile');
|
|
66
|
+
const profile = Object.freeze({
|
|
67
|
+
id: requireBoundedString(profileInput.id, 'profile.id', 80),
|
|
68
|
+
version: Number(profileInput.version),
|
|
69
|
+
domainField: path(profileInput.domainField, 'profile.domainField'),
|
|
70
|
+
summaryField: path(profileInput.summaryField, 'profile.summaryField'),
|
|
71
|
+
observedAtField: path(profileInput.observedAtField, 'profile.observedAtField'),
|
|
72
|
+
referenceField: path(profileInput.referenceField, 'profile.referenceField', true),
|
|
73
|
+
completenessField: path(profileInput.completenessField, 'profile.completenessField', true),
|
|
74
|
+
category: profileInput.category,
|
|
75
|
+
evidenceClass: profileInput.evidenceClass,
|
|
76
|
+
limitations: limitations(profileInput.limitations),
|
|
77
|
+
});
|
|
78
|
+
if (!Number.isSafeInteger(profile.version) || profile.version < 1 || profile.version > 1_000)
|
|
79
|
+
throw new TypeError('profile.version is invalid.');
|
|
80
|
+
if (typeof profile.category !== 'string' || !CATEGORIES.has(profile.category))
|
|
81
|
+
throw new TypeError('profile.category is unsupported.');
|
|
82
|
+
if (profile.evidenceClass !== 'deployment_observation' && profile.evidenceClass !== 'provider_report')
|
|
83
|
+
throw new TypeError('profile.evidenceClass is unsupported.');
|
|
84
|
+
if (!Array.isArray(input.records) || input.records.length < 1 || input.records.length > MAX_EXTERNAL_OBSERVATION_RECORDS) {
|
|
85
|
+
throw new TypeError(`records must contain between 1 and ${MAX_EXTERNAL_OBSERVATION_RECORDS} entries.`);
|
|
86
|
+
}
|
|
87
|
+
const source = Object.freeze({
|
|
88
|
+
name: requireBoundedString(sourceInput.name, 'source.name', 80),
|
|
89
|
+
reference: optionalText(sourceInput.reference, 'source.reference', 500),
|
|
90
|
+
collectedAt: sourceInput.collectedAt === null || sourceInput.collectedAt === undefined
|
|
91
|
+
? null
|
|
92
|
+
: requireIsoTimestamp(sourceInput.collectedAt, 'source.collectedAt'),
|
|
93
|
+
});
|
|
94
|
+
const candidates = input.records.map((record, index) => {
|
|
95
|
+
const domainValue = domain(readPath(record, profile.domainField), `records[${index}].${profile.domainField}`);
|
|
96
|
+
const summary = requireBoundedString(readPath(record, profile.summaryField), `records[${index}].${profile.summaryField}`, 900);
|
|
97
|
+
const observedAt = requireIsoTimestamp(readPath(record, profile.observedAtField), `records[${index}].${profile.observedAtField}`);
|
|
98
|
+
const completenessValue = profile.completenessField ? readPath(record, profile.completenessField) : 'unknown';
|
|
99
|
+
if (typeof completenessValue !== 'string' || !COMPLETENESS.has(completenessValue)) {
|
|
100
|
+
throw new TypeError(`records[${index}] completeness is unsupported.`);
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
domain: domainValue,
|
|
104
|
+
category: profile.category,
|
|
105
|
+
evidenceClass: profile.evidenceClass,
|
|
106
|
+
summary,
|
|
107
|
+
observedAt,
|
|
108
|
+
completeness: completenessValue,
|
|
109
|
+
limitations: profile.limitations,
|
|
110
|
+
reference: profile.referenceField
|
|
111
|
+
? optionalText(readPath(record, profile.referenceField), `records[${index}].${profile.referenceField}`, 500)
|
|
112
|
+
: null,
|
|
113
|
+
structuredObservation: null,
|
|
114
|
+
});
|
|
115
|
+
}).sort((left, right) => left.domain.localeCompare(right.domain) || left.observedAt.localeCompare(right.observedAt) || left.summary.localeCompare(right.summary));
|
|
116
|
+
const selected = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const domainCounts = new Map();
|
|
119
|
+
const domains = new Set();
|
|
120
|
+
for (const finding of candidates) {
|
|
121
|
+
const key = `${finding.domain}\u0000${finding.category}\u0000${finding.summary}\u0000${finding.observedAt}`;
|
|
122
|
+
if (seen.has(key))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
if (!domains.has(finding.domain) && domains.size >= MAX_EXTERNAL_FINDING_DOMAINS)
|
|
126
|
+
continue;
|
|
127
|
+
const count = domainCounts.get(finding.domain) ?? 0;
|
|
128
|
+
if (count >= MAX_EXTERNAL_FINDINGS_PER_DOMAIN)
|
|
129
|
+
continue;
|
|
130
|
+
if (selected.length >= MAX_EXTERNAL_FINDINGS)
|
|
131
|
+
continue;
|
|
132
|
+
domains.add(finding.domain);
|
|
133
|
+
domainCounts.set(finding.domain, count + 1);
|
|
134
|
+
selected.push(finding);
|
|
135
|
+
}
|
|
136
|
+
const truncated = selected.length < seen.size;
|
|
137
|
+
const document = {
|
|
138
|
+
schema: 'whoisleuth.external-findings',
|
|
139
|
+
schemaVersion: 3,
|
|
140
|
+
source,
|
|
141
|
+
findings: selected.map((finding) => ({
|
|
142
|
+
...finding,
|
|
143
|
+
limitations: [
|
|
144
|
+
...finding.limitations,
|
|
145
|
+
`Mapped locally with declarative profile ${profile.id} version ${profile.version}; the profile does not verify the upstream field semantics.`,
|
|
146
|
+
...(truncated ? ['The supplied records exceeded the browser import boundary; only the first deterministic bounded findings were retained.'] : []),
|
|
147
|
+
].slice(0, 8),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
return parseExternalFindingsDocument(document);
|
|
151
|
+
}
|
|
152
|
+
export function formatExternalObservationMapping(document) {
|
|
153
|
+
return [
|
|
154
|
+
'External observation mapping',
|
|
155
|
+
`Source ${document.source.name}`,
|
|
156
|
+
`Findings ${document.findings.length}`,
|
|
157
|
+
`Domains ${new Set(document.findings.map((finding) => finding.domain)).size}`,
|
|
158
|
+
'',
|
|
159
|
+
'The JSON form can be deliberately imported through the Console external-findings workflow.',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
export const CLI_FAIL_POLICIES = ['source-failure', 'inconclusive', 'danger', 'material-drift'];
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
export function parseCliFailPolicies(value) {
|
|
8
|
+
if (typeof value !== 'string' || !value || value.length > 100)
|
|
9
|
+
throw new CliUsageError('--fail-on requires a comma-separated policy list.');
|
|
10
|
+
const policies = [...new Set(value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean))];
|
|
11
|
+
if (!policies.length || policies.some((item) => !CLI_FAIL_POLICIES.includes(item))) {
|
|
12
|
+
throw new CliUsageError(`--fail-on supports: ${CLI_FAIL_POLICIES.join(', ')}.`);
|
|
13
|
+
}
|
|
14
|
+
return policies;
|
|
15
|
+
}
|
|
16
|
+
function availabilityStates(document) {
|
|
17
|
+
const direct = String(record(document.availability).state || '').toLowerCase();
|
|
18
|
+
const results = Array.isArray(document.results) ? document.results : [];
|
|
19
|
+
const resultStates = results.flatMap((value) => {
|
|
20
|
+
const item = record(value);
|
|
21
|
+
const state = item.availabilityState ?? record(item.availability).state;
|
|
22
|
+
return typeof state === 'string' ? [state.toLowerCase()] : [];
|
|
23
|
+
});
|
|
24
|
+
return [direct, ...resultStates].filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function evaluateCliFailPolicies(document, policies) {
|
|
27
|
+
const root = record(document);
|
|
28
|
+
const findings = [];
|
|
29
|
+
for (const policy of policies) {
|
|
30
|
+
if (policy === 'source-failure') {
|
|
31
|
+
const resultDocuments = Array.isArray(root.results) ? root.results.map(record).filter((item) => Object.keys(record(item.diagnostics)).length) : [];
|
|
32
|
+
const sourceFindings = Object.keys(record(root.diagnostics)).length
|
|
33
|
+
? lookupStrictExitFindings(root)
|
|
34
|
+
: resultDocuments.flatMap((item) => lookupStrictExitFindings(item));
|
|
35
|
+
const collection = record(root.collection);
|
|
36
|
+
const summary = record(root.summary);
|
|
37
|
+
const failed = Number(collection.failed ?? summary.failed ?? 0);
|
|
38
|
+
if (sourceFindings.length || failed > 0)
|
|
39
|
+
findings.push({ policy, reason: `${sourceFindings.length + failed} failed or incomplete collection state(s)` });
|
|
40
|
+
}
|
|
41
|
+
else if (policy === 'inconclusive') {
|
|
42
|
+
const count = availabilityStates(root).filter((state) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(state)).length;
|
|
43
|
+
if (count)
|
|
44
|
+
findings.push({ policy, reason: `${count} authority or availability state(s) remained inconclusive` });
|
|
45
|
+
}
|
|
46
|
+
else if (policy === 'danger') {
|
|
47
|
+
const risk = Number(record(root.risk).score ?? root.riskScore);
|
|
48
|
+
const severity = String(root.severity || record(root.review).severity || '').toLowerCase();
|
|
49
|
+
if ((Number.isFinite(risk) && risk >= 70) || severity === 'danger' || severity === 'critical') {
|
|
50
|
+
findings.push({ policy, reason: Number.isFinite(risk) ? `risk score ${risk} met the 70 review threshold` : `explicit ${severity} severity was present` });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const summary = record(record(root.flightRecorder).summary);
|
|
55
|
+
const unexpected = Number(summary.unexpectedChanges ?? 0);
|
|
56
|
+
if (unexpected > 0)
|
|
57
|
+
findings.push({ policy, reason: `${unexpected} unexpected complete observation change(s)` });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
|
62
|
+
export function formatFailPolicyNotice(findings) {
|
|
63
|
+
return `Failure policy matched: ${findings.map((item) => `${item.policy} (${item.reason})`).join('; ')}.\n`;
|
|
64
|
+
}
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -12,7 +12,7 @@ function renderGroups(groups) {
|
|
|
12
12
|
}
|
|
13
13
|
function renderComparison(comparison) {
|
|
14
14
|
if (!comparison.fields.length)
|
|
15
|
-
return '<p class="empty">No comparable
|
|
15
|
+
return '<p class="empty">No comparable normalised fields were published.</p>';
|
|
16
16
|
const rows = comparison.fields.map((field) => `<tr><th scope="row">${escapeHtml(field.label)}</th><td><span class="status">${escapeHtml(field.status)}</span></td><td>${escapeHtml(field.rdap)}</td><td>${escapeHtml(field.whois)}</td></tr>`).join('');
|
|
17
17
|
const omitted = comparison.omitted
|
|
18
18
|
? `<p class="omission">${escapeHtml(comparison.omitted)} additional comparison fields omitted.</p>`
|
|
@@ -66,4 +66,4 @@ function formatLookupEvidenceHtml(document) {
|
|
|
66
66
|
</html>
|
|
67
67
|
`;
|
|
68
68
|
}
|
|
69
|
-
export {
|
|
69
|
+
export { formatLookupEvidenceHtml };
|
package/cli/formatters/json.mjs
CHANGED
|
@@ -17,7 +17,13 @@ const CLI_POSTURE_SCHEMA_VERSION = 1;
|
|
|
17
17
|
const CLI_HTTP_SCHEMA_VERSION = 1;
|
|
18
18
|
const CLI_TLS_SCHEMA_VERSION = 1;
|
|
19
19
|
const CLI_COMPARE_SCHEMA_VERSION = 3;
|
|
20
|
-
function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast') {
|
|
20
|
+
function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast', collectionContext = {}) {
|
|
21
|
+
const observerLabel = typeof collectionContext.observerLabel === 'string'
|
|
22
|
+
? collectionContext.observerLabel
|
|
23
|
+
: null;
|
|
24
|
+
const vantageLabel = typeof collectionContext.vantageLabel === 'string'
|
|
25
|
+
? collectionContext.vantageLabel
|
|
26
|
+
: null;
|
|
21
27
|
return {
|
|
22
28
|
schema: CLI_LOOKUP_SCHEMA,
|
|
23
29
|
version: CLI_LOOKUP_SCHEMA_VERSION,
|
|
@@ -28,6 +34,12 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
|
|
|
28
34
|
inputHostname: classified.inputHostname,
|
|
29
35
|
registrableDomain: classified.registrableDomain,
|
|
30
36
|
isSubdomain: classified.isSubdomain,
|
|
37
|
+
...((observerLabel || vantageLabel) ? {
|
|
38
|
+
collectionContext: {
|
|
39
|
+
...(observerLabel ? { observerLabel } : {}),
|
|
40
|
+
...(vantageLabel ? { vantageLabel } : {}),
|
|
41
|
+
},
|
|
42
|
+
} : {}),
|
|
31
43
|
...result,
|
|
32
44
|
};
|
|
33
45
|
}
|
|
@@ -593,7 +593,7 @@ function formatTerminalCompare(document) {
|
|
|
593
593
|
}
|
|
594
594
|
lines.push('');
|
|
595
595
|
if (!fields.length) {
|
|
596
|
-
lines.push('Neither source published a comparable
|
|
596
|
+
lines.push('Neither source published a comparable normalised field.');
|
|
597
597
|
}
|
|
598
598
|
else {
|
|
599
599
|
for (const value of fields) {
|
|
@@ -613,7 +613,7 @@ function formatTerminalCompare(document) {
|
|
|
613
613
|
const publicationDifferences = publicationFields.length - (Number(publicationCounts.equivalent) || 0);
|
|
614
614
|
lines.push('', 'Registry / registrar RDAP publication', `Registry RDAP ${comparisonStatusLabel(registryHealth.status)}`, `Registrar RDAP ${comparisonStatusLabel(registrarHealth.status)}`, `Compared ${safeTerminalValue(publicationFields.length, '0')} field${publicationFields.length === 1 ? '' : 's'}`, `Equivalent ${safeTerminalValue(publicationCounts.equivalent, '0')}`, `Differences ${safeTerminalValue(publicationDifferences, '0')}`, '');
|
|
615
615
|
if (!publicationFields.length) {
|
|
616
|
-
lines.push('Neither RDAP publication exposed a comparable
|
|
616
|
+
lines.push('Neither RDAP publication exposed a comparable normalised field.');
|
|
617
617
|
}
|
|
618
618
|
else {
|
|
619
619
|
for (const value of publicationFields) {
|
|
@@ -635,6 +635,7 @@ function formatTerminalRiskCalibration(document) {
|
|
|
635
635
|
const bands = terminalRecord(summary.scoreBands);
|
|
636
636
|
const thresholds = Array.isArray(document.thresholds) ? document.thresholds : [];
|
|
637
637
|
const records = Array.isArray(document.records) ? document.records : [];
|
|
638
|
+
const comparison = terminalRecord(document.modelComparison);
|
|
638
639
|
const visible = records.slice(0, MAX_RISK_CALIBRATION_TERMINAL_RECORDS);
|
|
639
640
|
const lines = [
|
|
640
641
|
`Risk model v${safeTerminalValue(document.riskModelVersion)}`,
|
|
@@ -648,7 +649,10 @@ function formatTerminalRiskCalibration(document) {
|
|
|
648
649
|
];
|
|
649
650
|
for (const value of thresholds) {
|
|
650
651
|
const threshold = terminalRecord(value);
|
|
651
|
-
lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)}`);
|
|
652
|
+
lines.push(`${String(safeTerminalValue(threshold.threshold)).padStart(3)}+ TP ${safeTerminalValue(threshold.truePositive, '0')} FP ${safeTerminalValue(threshold.falsePositive, '0')} TN ${safeTerminalValue(threshold.trueNegative, '0')} FN ${safeTerminalValue(threshold.falseNegative, '0')} precision ${safeTerminalValue(threshold.precision)} recall ${safeTerminalValue(threshold.recall)} F1 ${safeTerminalValue(threshold.f1)}`);
|
|
653
|
+
}
|
|
654
|
+
if (comparison.available === true) {
|
|
655
|
+
lines.push('', `Model comparison v${safeTerminalValue(comparison.previousModelVersion)} → v${safeTerminalValue(comparison.currentModelVersion)} · ${safeTerminalValue(comparison.scoresChanged, '0')} score changes · ${safeTerminalValue(comparison.thresholdClassificationsChanged, '0')} review-band changes`);
|
|
652
656
|
}
|
|
653
657
|
lines.push('', 'Records');
|
|
654
658
|
for (const value of visible) {
|