@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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
|
|
4
|
+
import { CliUsageError } from "./errors.mjs";
|
|
5
|
+
import { writePrivateFile } from "./output-file.mjs";
|
|
6
|
+
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
7
|
+
export const CLI_DISCOVERY_OBSERVATION_SCHEMA = 'whoisleuth.cli.discovery-observation-snapshot';
|
|
8
|
+
export const CLI_DISCOVERY_OBSERVATION_VERSION = 2;
|
|
9
|
+
export const MAX_DISCOVERY_OBSERVATION_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_DISCOVERY_OBSERVATIONS = 500;
|
|
11
|
+
const SUPPORTED_DISCOVERY_OBSERVATION_VERSIONS = new Set([1, CLI_DISCOVERY_OBSERVATION_VERSION]);
|
|
12
|
+
function record(value) {
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
|
+
}
|
|
15
|
+
function domainValue(value) {
|
|
16
|
+
const domain = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
17
|
+
if (!isValidAsciiDomainName(domain, { requireDot: true, requireLowercase: true })) {
|
|
18
|
+
throw new CliUsageError('Discovery observation snapshot contains an invalid domain.');
|
|
19
|
+
}
|
|
20
|
+
return domain;
|
|
21
|
+
}
|
|
22
|
+
function normalizedTimestamp(value, label, optional = false) {
|
|
23
|
+
if (optional && value === null)
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
26
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
27
|
+
}
|
|
28
|
+
const parsed = Date.parse(value);
|
|
29
|
+
if (!Number.isFinite(parsed))
|
|
30
|
+
throw new CliUsageError(`${label} is invalid.`);
|
|
31
|
+
return new Date(parsed).toISOString();
|
|
32
|
+
}
|
|
33
|
+
function latestObservedAt(registrationObservedAt, dnsObservedAt) {
|
|
34
|
+
if (!registrationObservedAt)
|
|
35
|
+
return dnsObservedAt;
|
|
36
|
+
if (!dnsObservedAt)
|
|
37
|
+
return registrationObservedAt;
|
|
38
|
+
return registrationObservedAt >= dnsObservedAt ? registrationObservedAt : dnsObservedAt;
|
|
39
|
+
}
|
|
40
|
+
function snapshotConfigurationDigest(configuration) {
|
|
41
|
+
return createHash('sha256').update(JSON.stringify({
|
|
42
|
+
domains: [...configuration.domains],
|
|
43
|
+
mode: configuration.deep ? 'deep' : 'fast',
|
|
44
|
+
resolverServers: [...configuration.resolverServers],
|
|
45
|
+
})).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
function normalizeDns(value) {
|
|
48
|
+
const dns = record(value);
|
|
49
|
+
const stringList = (candidate, maximum) => Array.isArray(candidate)
|
|
50
|
+
? candidate.flatMap((item) => typeof item === 'string' && item.length <= 500 ? [item] : []).slice(0, maximum)
|
|
51
|
+
: [];
|
|
52
|
+
const nullableBoolean = (candidate) => typeof candidate === 'boolean' ? candidate : null;
|
|
53
|
+
if (typeof dns.status !== 'string' || !dns.status || dns.status.length > 40
|
|
54
|
+
|| /[\u0000-\u001f\u007f]/u.test(dns.status))
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
status: dns.status,
|
|
58
|
+
a: stringList(dns.a, 100),
|
|
59
|
+
aaaa: stringList(dns.aaaa, 100),
|
|
60
|
+
ns: stringList(dns.ns, 100),
|
|
61
|
+
mx: stringList(dns.mx, 100),
|
|
62
|
+
hasNullMx: nullableBoolean(dns.hasNullMx),
|
|
63
|
+
hasSpf: nullableBoolean(dns.hasSpf),
|
|
64
|
+
hasDmarc: nullableBoolean(dns.hasDmarc),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function normalizeObservation(value, version) {
|
|
68
|
+
const item = record(value);
|
|
69
|
+
const dns = normalizeDns(item.dns);
|
|
70
|
+
try {
|
|
71
|
+
const validAttemptStates = version === 1 ? ['error', 'success'] : ['error', 'partial', 'success'];
|
|
72
|
+
if (!dns
|
|
73
|
+
|| !validAttemptStates.includes(String(item.latestAttemptState))
|
|
74
|
+
|| typeof item.availabilityState !== 'string' || !item.availabilityState || item.availabilityState.length > 40
|
|
75
|
+
|| /[\u0000-\u001f\u007f]/u.test(item.availabilityState)
|
|
76
|
+
|| typeof item.confidence !== 'string' || !item.confidence || item.confidence.length > 40
|
|
77
|
+
|| /[\u0000-\u001f\u007f]/u.test(item.confidence))
|
|
78
|
+
return null;
|
|
79
|
+
const legacyObservedAt = normalizedTimestamp(item.observedAt, 'Discovery observation time', version >= 2);
|
|
80
|
+
const registrationObservedAt = version === 1
|
|
81
|
+
? item.availabilityState === 'unknown' ? null : legacyObservedAt
|
|
82
|
+
: normalizedTimestamp(item.registrationObservedAt, 'Discovery registration observation time', true);
|
|
83
|
+
const dnsObservedAt = version === 1
|
|
84
|
+
? ['partial', 'success'].includes(dns.status) ? legacyObservedAt : null
|
|
85
|
+
: normalizedTimestamp(item.dnsObservedAt, 'Discovery DNS observation time', true);
|
|
86
|
+
const observedAt = latestObservedAt(registrationObservedAt, dnsObservedAt);
|
|
87
|
+
if (version >= 2 && legacyObservedAt !== observedAt)
|
|
88
|
+
return null;
|
|
89
|
+
const latestRegistrationState = version === 1
|
|
90
|
+
? registrationObservedAt ? 'observed' : 'unavailable'
|
|
91
|
+
: item.latestRegistrationState;
|
|
92
|
+
const latestDnsState = version === 1 ? dns.status : item.latestDnsState;
|
|
93
|
+
if (!['observed', 'unavailable'].includes(String(latestRegistrationState))
|
|
94
|
+
|| typeof latestDnsState !== 'string' || !latestDnsState || latestDnsState.length > 40
|
|
95
|
+
|| /[\u0000-\u001f\u007f]/u.test(latestDnsState))
|
|
96
|
+
return null;
|
|
97
|
+
if (latestRegistrationState === 'observed' && registrationObservedAt === null)
|
|
98
|
+
return null;
|
|
99
|
+
return {
|
|
100
|
+
domain: domainValue(item.domain),
|
|
101
|
+
observedAt,
|
|
102
|
+
registrationObservedAt,
|
|
103
|
+
dnsObservedAt,
|
|
104
|
+
latestAttemptAt: normalizedTimestamp(item.latestAttemptAt, 'Discovery latest-attempt time') ?? '',
|
|
105
|
+
latestAttemptState: item.latestAttemptState,
|
|
106
|
+
latestRegistrationState: latestRegistrationState,
|
|
107
|
+
latestDnsState,
|
|
108
|
+
availabilityState: item.availabilityState,
|
|
109
|
+
confidence: item.confidence,
|
|
110
|
+
dns,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function parseObservationSnapshot(text, expectedDigest) {
|
|
118
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_OBSERVATION_BYTES) {
|
|
119
|
+
throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
|
|
120
|
+
}
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new CliUsageError('Discovery observation snapshot must be valid JSON.');
|
|
127
|
+
}
|
|
128
|
+
const document = record(parsed);
|
|
129
|
+
const version = typeof document.version === 'number' && Number.isSafeInteger(document.version)
|
|
130
|
+
? document.version
|
|
131
|
+
: 0;
|
|
132
|
+
if (document.schema !== CLI_DISCOVERY_OBSERVATION_SCHEMA || !SUPPORTED_DISCOVERY_OBSERVATION_VERSIONS.has(version)) {
|
|
133
|
+
throw new CliUsageError(`Discovery observation snapshot must use ${CLI_DISCOVERY_OBSERVATION_SCHEMA} version 1 or ${CLI_DISCOVERY_OBSERVATION_VERSION}.`);
|
|
134
|
+
}
|
|
135
|
+
if (document.configurationDigestSha256 !== expectedDigest) {
|
|
136
|
+
throw new CliUsageError('Discovery observation snapshot does not match this candidate set, scan mode, or resolver selection.');
|
|
137
|
+
}
|
|
138
|
+
if (typeof document.generatedAt !== 'string' || !Number.isFinite(Date.parse(document.generatedAt))
|
|
139
|
+
|| !Array.isArray(document.observations) || document.observations.length > MAX_DISCOVERY_OBSERVATIONS) {
|
|
140
|
+
throw new CliUsageError('Discovery observation snapshot metadata is invalid.');
|
|
141
|
+
}
|
|
142
|
+
const observations = document.observations.map((item) => normalizeObservation(item, version));
|
|
143
|
+
if (observations.some((item) => item === null) || document.observationCount !== observations.length) {
|
|
144
|
+
throw new CliUsageError('Discovery observation snapshot contains invalid evidence.');
|
|
145
|
+
}
|
|
146
|
+
const normalized = observations;
|
|
147
|
+
if (new Set(normalized.map((item) => item.domain)).size !== normalized.length) {
|
|
148
|
+
throw new CliUsageError('Discovery observation snapshot contains duplicate domains.');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
schema: CLI_DISCOVERY_OBSERVATION_SCHEMA,
|
|
152
|
+
version: CLI_DISCOVERY_OBSERVATION_VERSION,
|
|
153
|
+
generatedAt: normalizedTimestamp(document.generatedAt, 'Discovery snapshot generation time') ?? '',
|
|
154
|
+
configurationDigestSha256: expectedDigest,
|
|
155
|
+
observationCount: normalized.length,
|
|
156
|
+
observations: normalized,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function readExisting(path) {
|
|
160
|
+
try {
|
|
161
|
+
const handle = await open(path, 'r');
|
|
162
|
+
try {
|
|
163
|
+
const metadata = await handle.stat();
|
|
164
|
+
if (!metadata.isFile() || metadata.size > MAX_DISCOVERY_OBSERVATION_BYTES) {
|
|
165
|
+
throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
|
|
166
|
+
}
|
|
167
|
+
return await handle.readFile({ encoding: 'utf8' });
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await handle.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
175
|
+
return null;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function currentAttempt(domain, item, previous, generatedAt, deep) {
|
|
180
|
+
const availability = item.ok ? record(record(item.result).availability) : {};
|
|
181
|
+
const currentAvailabilityState = availabilityState(item) || 'unknown';
|
|
182
|
+
const registrationObserved = item.ok && currentAvailabilityState !== 'unknown';
|
|
183
|
+
const currentDns = bulkDnsSummary(item);
|
|
184
|
+
const dnsComplete = deep && currentDns.status === 'success';
|
|
185
|
+
const dnsRetainable = deep && currentDns.status === 'partial';
|
|
186
|
+
const previousDnsRetainable = previous && ['partial', 'success'].includes(previous.dns.status);
|
|
187
|
+
const dns = dnsComplete || (dnsRetainable && !previousDnsRetainable)
|
|
188
|
+
? currentDns
|
|
189
|
+
: previousDnsRetainable ? previous.dns : currentDns;
|
|
190
|
+
const registrationObservedAt = registrationObserved ? generatedAt : previous?.registrationObservedAt ?? null;
|
|
191
|
+
const dnsObservedAt = dnsComplete || (dnsRetainable && !previousDnsRetainable)
|
|
192
|
+
? generatedAt
|
|
193
|
+
: previous?.dnsObservedAt ?? null;
|
|
194
|
+
const unavailableComponents = [];
|
|
195
|
+
if (!registrationObserved)
|
|
196
|
+
unavailableComponents.push('registration');
|
|
197
|
+
if (deep && !dnsComplete)
|
|
198
|
+
unavailableComponents.push('dns');
|
|
199
|
+
const attemptState = !item.ok
|
|
200
|
+
? 'error'
|
|
201
|
+
: unavailableComponents.length ? 'partial' : 'success';
|
|
202
|
+
const confidence = registrationObserved && typeof availability.confidence === 'string'
|
|
203
|
+
? availability.confidence.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 40) || 'unknown'
|
|
204
|
+
: previous?.confidence ?? 'unknown';
|
|
205
|
+
const retainedAvailabilityState = registrationObserved
|
|
206
|
+
? currentAvailabilityState
|
|
207
|
+
: previous?.availabilityState ?? 'unknown';
|
|
208
|
+
return {
|
|
209
|
+
registrationObserved,
|
|
210
|
+
dnsComplete,
|
|
211
|
+
unavailableComponents,
|
|
212
|
+
observation: {
|
|
213
|
+
domain,
|
|
214
|
+
observedAt: latestObservedAt(registrationObservedAt, dnsObservedAt),
|
|
215
|
+
registrationObservedAt,
|
|
216
|
+
dnsObservedAt,
|
|
217
|
+
latestAttemptAt: generatedAt,
|
|
218
|
+
latestAttemptState: attemptState,
|
|
219
|
+
latestRegistrationState: registrationObserved ? 'observed' : 'unavailable',
|
|
220
|
+
latestDnsState: deep ? currentDns.status : 'not_requested',
|
|
221
|
+
availabilityState: retainedAvailabilityState,
|
|
222
|
+
confidence,
|
|
223
|
+
dns,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function materialChanges(previous, current, attempt = {
|
|
228
|
+
dnsComplete: current.latestDnsState === 'success',
|
|
229
|
+
registrationObserved: current.latestRegistrationState === 'observed',
|
|
230
|
+
}) {
|
|
231
|
+
const changes = [];
|
|
232
|
+
const compare = (field, before, after) => {
|
|
233
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
234
|
+
changes.push({ field, before, after });
|
|
235
|
+
};
|
|
236
|
+
if (attempt.registrationObserved) {
|
|
237
|
+
compare('availabilityState', previous.availabilityState, current.availabilityState);
|
|
238
|
+
compare('confidence', previous.confidence, current.confidence);
|
|
239
|
+
}
|
|
240
|
+
if (attempt.dnsComplete) {
|
|
241
|
+
for (const field of ['a', 'aaaa', 'ns', 'mx', 'hasNullMx', 'hasSpf', 'hasDmarc']) {
|
|
242
|
+
compare(`dns.${field}`, previous.dns[field], current.dns[field]);
|
|
243
|
+
}
|
|
244
|
+
compare('dns.status', previous.dns.status, current.dns.status);
|
|
245
|
+
}
|
|
246
|
+
return changes;
|
|
247
|
+
}
|
|
248
|
+
async function updateDiscoveryObservationSnapshot(path, candidates, items, configuration, generatedAt) {
|
|
249
|
+
if (!path || path.length > 1024 || /[\u0000-\u001f\u007f]/u.test(path)) {
|
|
250
|
+
throw new CliUsageError('Discovery observation snapshot path must be bounded text without control characters.');
|
|
251
|
+
}
|
|
252
|
+
if (candidates.length !== items.length || candidates.length > MAX_DISCOVERY_OBSERVATIONS) {
|
|
253
|
+
throw new CliUsageError(`Discovery observation snapshots support at most ${MAX_DISCOVERY_OBSERVATIONS} aligned results.`);
|
|
254
|
+
}
|
|
255
|
+
const currentGeneratedAt = normalizedTimestamp(generatedAt, 'Discovery observation generation time') ?? '';
|
|
256
|
+
const domains = candidates.map((candidate) => domainValue(candidate.domain));
|
|
257
|
+
const digest = snapshotConfigurationDigest({ domains, ...configuration });
|
|
258
|
+
const priorText = await readExisting(path);
|
|
259
|
+
const prior = priorText === null ? null : parseObservationSnapshot(priorText, digest);
|
|
260
|
+
const priorByDomain = new Map((prior?.observations || []).map((item) => [item.domain, item]));
|
|
261
|
+
const observations = [];
|
|
262
|
+
const changed = [];
|
|
263
|
+
const unavailable = [];
|
|
264
|
+
const unavailableComponents = [];
|
|
265
|
+
for (let index = 0; index < domains.length; index++) {
|
|
266
|
+
const domain = domains[index];
|
|
267
|
+
const item = items[index];
|
|
268
|
+
if (!domain || !item)
|
|
269
|
+
throw new CliUsageError('Discovery observation snapshot alignment failed.');
|
|
270
|
+
const previous = priorByDomain.get(domain);
|
|
271
|
+
const attempt = currentAttempt(domain, item, previous, currentGeneratedAt, configuration.deep);
|
|
272
|
+
const current = attempt.observation;
|
|
273
|
+
if (attempt.unavailableComponents.length) {
|
|
274
|
+
unavailable.push(domain);
|
|
275
|
+
unavailableComponents.push({ domain, components: attempt.unavailableComponents });
|
|
276
|
+
}
|
|
277
|
+
observations.push(current);
|
|
278
|
+
if (previous) {
|
|
279
|
+
const changes = materialChanges(previous, current, attempt);
|
|
280
|
+
if (changes.length)
|
|
281
|
+
changed.push({ domain, changes });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const document = {
|
|
285
|
+
schema: CLI_DISCOVERY_OBSERVATION_SCHEMA,
|
|
286
|
+
version: CLI_DISCOVERY_OBSERVATION_VERSION,
|
|
287
|
+
generatedAt: currentGeneratedAt,
|
|
288
|
+
configurationDigestSha256: digest,
|
|
289
|
+
observationCount: observations.length,
|
|
290
|
+
observations,
|
|
291
|
+
};
|
|
292
|
+
const content = `${JSON.stringify(document, null, 2)}\n`;
|
|
293
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_DISCOVERY_OBSERVATION_BYTES) {
|
|
294
|
+
throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
|
|
295
|
+
}
|
|
296
|
+
await writePrivateFile(path, content, {
|
|
297
|
+
force: prior !== null,
|
|
298
|
+
existingFileMessage: 'Discovery observation snapshot already exists and could not be replaced.',
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
baselineCreated: prior === null,
|
|
302
|
+
previousGeneratedAt: prior?.generatedAt ?? null,
|
|
303
|
+
currentGeneratedAt,
|
|
304
|
+
changed,
|
|
305
|
+
unavailable,
|
|
306
|
+
unavailableComponents,
|
|
307
|
+
unchanged: prior === null ? 0 : domains.filter((domain) => !changed.some((item) => item.domain === domain) && !unavailable.includes(domain)).length,
|
|
308
|
+
limitations: [
|
|
309
|
+
'Only bounded registration-state and DNS summaries are retained; raw registry records, contacts, page contents, and request details are excluded.',
|
|
310
|
+
'Registration and DNS retain separate observation times. A failed or partial component preserves its previous usable evidence and is reported as unavailable rather than as a removal or negative finding.',
|
|
311
|
+
'Material differences are observation changes for analyst review and do not establish ownership, control, intent, safety, or maliciousness.',
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
export { materialChanges, parseObservationSnapshot, snapshotConfigurationDigest, updateDiscoveryObservationSnapshot, };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { classifyQuery } from "../lib/classify.mjs";
|
|
3
|
+
import { normalizeSelectedDnsResolvers } from "../lib/dns-resolver-selection.mjs";
|
|
4
|
+
import { runUnifiedLookup } from "../lib/lookup.mjs";
|
|
5
|
+
import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
|
|
6
|
+
import { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist, readDiscoveryScanListBounded, runDiscoveryScanChunks, } from "./discovery-scan.mjs";
|
|
7
|
+
import { updateDiscoveryObservationSnapshot } from "./discovery-observation-snapshot.mjs";
|
|
8
|
+
import { generateDiscoveryCandidates } from "./discovery-workflow.mjs";
|
|
9
|
+
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
10
|
+
import EXIT_CODES from "./exit-codes.mjs";
|
|
11
|
+
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
12
|
+
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
13
|
+
async function readAllowlist(source, dependencies, classify) {
|
|
14
|
+
if (!source)
|
|
15
|
+
return new Set();
|
|
16
|
+
try {
|
|
17
|
+
const text = dependencies.readDiscoveryAllowlist
|
|
18
|
+
? await dependencies.readDiscoveryAllowlist(source)
|
|
19
|
+
: await readDiscoveryScanListBounded(createReadStream(source, { highWaterMark: 64 * 1024 }));
|
|
20
|
+
return parseDiscoveryScanAllowlist(text, classify);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof CliUsageError)
|
|
24
|
+
throw error;
|
|
25
|
+
throw new CliUsageError(`Could not read discovery scan allowlist: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
29
|
+
const eventProgress = createCliProgressEvents(context.stderr, {
|
|
30
|
+
command: 'discover-scan',
|
|
31
|
+
enabled: args.events,
|
|
32
|
+
...(dependencies.now ? { now: dependencies.now } : {}),
|
|
33
|
+
});
|
|
34
|
+
context.setEventProgress(eventProgress);
|
|
35
|
+
eventProgress.emit({ event: 'started' });
|
|
36
|
+
const { metadata: generationMetadata, result } = await generateDiscoveryCandidates(args, dependencies, context);
|
|
37
|
+
const candidates = result.candidates
|
|
38
|
+
.filter((candidate) => typeof candidate.domain === 'string' && candidate.domain.length > 0)
|
|
39
|
+
.slice(0, args.scanLimit);
|
|
40
|
+
if (!candidates.length)
|
|
41
|
+
throw new CliUsageError('discover-scan did not generate any valid candidates to collect.');
|
|
42
|
+
const queries = candidates.map((candidate) => String(candidate.domain));
|
|
43
|
+
const classify = dependencies.classifyQuery || classifyQuery;
|
|
44
|
+
let resolverServers = [];
|
|
45
|
+
if (args.resolverText) {
|
|
46
|
+
try {
|
|
47
|
+
resolverServers = normalizeSelectedDnsResolvers(args.resolverText);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid DNS resolver selection'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const allowlist = await readAllowlist(args.allowlistSource, dependencies, classify);
|
|
54
|
+
const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
|
|
55
|
+
const checkpoint = args.checkpoint
|
|
56
|
+
? await checkpointWriter({
|
|
57
|
+
path: args.checkpoint,
|
|
58
|
+
queries,
|
|
59
|
+
deep: args.deep,
|
|
60
|
+
resume: args.resume,
|
|
61
|
+
classifyQuery: classify,
|
|
62
|
+
...(dependencies.now ? { now: dependencies.now } : {}),
|
|
63
|
+
})
|
|
64
|
+
: null;
|
|
65
|
+
const indicator = context.beginProgress(`Collecting 0 of ${queries.length} generated candidates`);
|
|
66
|
+
let completed = checkpoint?.initialResults.length || 0;
|
|
67
|
+
if (completed)
|
|
68
|
+
indicator.update(`Resumed ${completed} of ${queries.length} generated candidates`);
|
|
69
|
+
let checkpointFailure = null;
|
|
70
|
+
let items;
|
|
71
|
+
try {
|
|
72
|
+
items = await runDiscoveryScanChunks(queries, {
|
|
73
|
+
deep: args.deep,
|
|
74
|
+
chunkSize: args.chunkSize,
|
|
75
|
+
concurrency: args.concurrency,
|
|
76
|
+
classifyQuery: classify,
|
|
77
|
+
runUnifiedLookup: dependencies.runUnifiedLookup || runUnifiedLookup,
|
|
78
|
+
...(resolverServers.length ? { dnsResolverServers: resolverServers } : {}),
|
|
79
|
+
...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
|
|
80
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
81
|
+
onItemSettled: (item) => {
|
|
82
|
+
completed += 1;
|
|
83
|
+
indicator.update(`Collected ${completed} of ${queries.length} generated candidates`);
|
|
84
|
+
eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
|
|
85
|
+
checkpoint?.record(item);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
context.endProgress();
|
|
91
|
+
try {
|
|
92
|
+
await checkpoint?.flush();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
checkpointFailure = error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const snapshot = args.observationSnapshot
|
|
99
|
+
? await updateDiscoveryObservationSnapshot(args.observationSnapshot, candidates, items, { deep: args.deep, resolverServers }, generationMetadata.generatedAt)
|
|
100
|
+
: null;
|
|
101
|
+
const document = buildDiscoveryScanDocument(candidates, items, {
|
|
102
|
+
generatedAt: generationMetadata.generatedAt,
|
|
103
|
+
seed: generationMetadata.seed,
|
|
104
|
+
preset: generationMetadata.preset,
|
|
105
|
+
keyboardLayout: generationMetadata.keyboardLayout,
|
|
106
|
+
tlds: generationMetadata.tlds,
|
|
107
|
+
mutationFamilies: generationMetadata.mutationFamilies,
|
|
108
|
+
generatedCandidateCount: result.candidates.length,
|
|
109
|
+
selectedCandidateCount: candidates.length,
|
|
110
|
+
scanLimit: args.scanLimit,
|
|
111
|
+
chunkSize: args.chunkSize,
|
|
112
|
+
concurrency: args.concurrency,
|
|
113
|
+
deep: args.deep,
|
|
114
|
+
filter: args.filter,
|
|
115
|
+
resolverServers,
|
|
116
|
+
}, allowlist, snapshot);
|
|
117
|
+
if (!args.quiet) {
|
|
118
|
+
if (args.output === 'json')
|
|
119
|
+
context.writeStdout(formatJsonDocument(document));
|
|
120
|
+
else if (args.output === 'jsonl')
|
|
121
|
+
context.writeStdout(formatDiscoveryScanJsonLines(document));
|
|
122
|
+
else if (args.output === 'csv')
|
|
123
|
+
context.writeStdout(formatDiscoveryScanCsv(document));
|
|
124
|
+
else if (args.output === 'domains')
|
|
125
|
+
context.writeStdout(formatDiscoveryScanDomains(document));
|
|
126
|
+
else
|
|
127
|
+
context.writeStdout(context.terminal(formatTerminalDiscoveryScan(document), args.color));
|
|
128
|
+
}
|
|
129
|
+
if (checkpointFailure) {
|
|
130
|
+
eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
|
|
131
|
+
if (!eventProgress.enabled) {
|
|
132
|
+
context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const exitCode = checkpointFailure || items.some((item) => !item.ok)
|
|
136
|
+
? EXIT_CODES.PARTIAL_FAILURE
|
|
137
|
+
: EXIT_CODES.SUCCESS;
|
|
138
|
+
eventProgress.emit({ event: 'completed', exitCode });
|
|
139
|
+
return exitCode;
|
|
140
|
+
}
|
|
141
|
+
export { runDiscoveryScanCommand };
|