@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,10 @@
|
|
|
1
|
+
function errorRecord(value) {
|
|
2
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
? value
|
|
4
|
+
: null;
|
|
5
|
+
}
|
|
6
|
+
function nonEmptyErrorMessage(value, fallback) {
|
|
7
|
+
const message = errorRecord(value)?.message;
|
|
8
|
+
return typeof message === 'string' && message.trim() ? message : fallback;
|
|
9
|
+
}
|
|
10
|
+
export { nonEmptyErrorMessage };
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
|
|
2
|
+
import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
3
|
+
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
4
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 23;
|
|
5
|
+
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
6
|
+
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
7
|
+
]);
|
|
8
|
+
const NETWORK_CONTEXT_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'error']);
|
|
9
|
+
const NETWORK_ADDRESS_SOURCES = new Set(['tls_connection', 'dns_a', 'dns_aaaa']);
|
|
10
|
+
const REVERSE_DNS_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error']);
|
|
11
|
+
const SECURITY_TXT_STATES = new Set(['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable']);
|
|
12
|
+
const SSLBL_STATES = new Set(['success', 'stale', 'unavailable']);
|
|
13
|
+
const SSLBL_VERDICTS = new Set(['listed', 'not_listed', 'inconclusive']);
|
|
14
|
+
function recordOrNull(value) {
|
|
15
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
16
|
+
}
|
|
17
|
+
function cloneJson(value) {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
function boundedString(value, maximum) {
|
|
23
|
+
if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
|
|
24
|
+
return null;
|
|
25
|
+
return value.replace(/\s+/g, ' ').trim() || null;
|
|
26
|
+
}
|
|
27
|
+
function boundedInteger(value, maximum) {
|
|
28
|
+
return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum ? Number(value) : null;
|
|
29
|
+
}
|
|
30
|
+
function boundedHttpStatus(value) {
|
|
31
|
+
return Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599 ? Number(value) : null;
|
|
32
|
+
}
|
|
33
|
+
function boundedTimestamp(value) {
|
|
34
|
+
const text = boundedString(value, 64);
|
|
35
|
+
if (!text)
|
|
36
|
+
return null;
|
|
37
|
+
const time = Date.parse(text);
|
|
38
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
function boundedEndpoint(value) {
|
|
41
|
+
const text = boundedString(value, 2048);
|
|
42
|
+
if (!text)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(text);
|
|
46
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
|
|
47
|
+
return null;
|
|
48
|
+
url.search = '';
|
|
49
|
+
url.hash = '';
|
|
50
|
+
return url.toString().slice(0, 2048);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function boundedStringList(value, count, length) {
|
|
57
|
+
return [...new Set((Array.isArray(value) ? value : [])
|
|
58
|
+
.slice(0, count)
|
|
59
|
+
.map((item) => boundedString(item, length))
|
|
60
|
+
.filter((item) => item !== null))];
|
|
61
|
+
}
|
|
62
|
+
function boundedHostname(value) {
|
|
63
|
+
const text = boundedString(value, 253)?.toLowerCase().replace(/\.+$/u, '') || null;
|
|
64
|
+
if (!text || !text.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
function boundedPublishedUri(value, protocols) {
|
|
70
|
+
const text = boundedString(value, 2048);
|
|
71
|
+
if (!text)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(text);
|
|
75
|
+
if (!protocols.includes(url.protocol) || url.username || url.password)
|
|
76
|
+
return null;
|
|
77
|
+
url.search = '';
|
|
78
|
+
url.hash = '';
|
|
79
|
+
return url.toString().slice(0, 2048);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function boundedUriList(value, protocols) {
|
|
86
|
+
return [...new Set((Array.isArray(value) ? value : [])
|
|
87
|
+
.slice(0, 10)
|
|
88
|
+
.map((item) => boundedPublishedUri(item, protocols))
|
|
89
|
+
.filter((item) => item !== null))];
|
|
90
|
+
}
|
|
91
|
+
function boundedSslblReferenceUrl(value, fingerprint, verdict) {
|
|
92
|
+
if (!fingerprint || verdict !== 'listed')
|
|
93
|
+
return null;
|
|
94
|
+
const text = boundedString(value, 2048);
|
|
95
|
+
if (!text)
|
|
96
|
+
return null;
|
|
97
|
+
const expectedPath = `/ssl-certificates/sha1/${fingerprint}/`;
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(text);
|
|
100
|
+
if (url.protocol !== 'https:'
|
|
101
|
+
|| url.hostname !== 'sslbl.abuse.ch'
|
|
102
|
+
|| url.port
|
|
103
|
+
|| url.username
|
|
104
|
+
|| url.password
|
|
105
|
+
|| url.pathname !== expectedPath) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return `https://sslbl.abuse.ch${expectedPath}`;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function securityTxtSource(value) {
|
|
115
|
+
const source = recordOrNull(value);
|
|
116
|
+
if (!source
|
|
117
|
+
|| source.securityTxtVersion !== 1
|
|
118
|
+
|| typeof source.state !== 'string'
|
|
119
|
+
|| !SECURITY_TXT_STATES.has(source.state))
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
securityTxtVersion: 1,
|
|
123
|
+
version: source.version === 1 ? 1 : null,
|
|
124
|
+
state: source.state,
|
|
125
|
+
status: boundedString(source.status, 40),
|
|
126
|
+
observedAt: boundedTimestamp(source.observedAt),
|
|
127
|
+
scanMode: source.scanMode === 'deep' ? 'deep' : null,
|
|
128
|
+
source: source.source === 'security_txt' ? 'security_txt' : null,
|
|
129
|
+
durationMs: boundedInteger(source.durationMs, 120_000),
|
|
130
|
+
complete: source.complete === true,
|
|
131
|
+
truncated: source.truncated === true,
|
|
132
|
+
limitations: boundedStringList(source.limitations, 10, 300),
|
|
133
|
+
detail: boundedString(source.detail, 300),
|
|
134
|
+
requestedUrl: boundedPublishedUri(source.requestedUrl, ['https:']),
|
|
135
|
+
finalUrl: boundedPublishedUri(source.finalUrl, ['https:']),
|
|
136
|
+
httpStatus: boundedHttpStatus(source.httpStatus),
|
|
137
|
+
redirectCount: boundedInteger(source.redirectCount, 3),
|
|
138
|
+
expiresAt: boundedTimestamp(source.expiresAt),
|
|
139
|
+
signed: source.signed === true,
|
|
140
|
+
canonicalMatches: typeof source.canonicalMatches === 'boolean' ? source.canonicalMatches : null,
|
|
141
|
+
contacts: boundedUriList(source.contacts, ['https:', 'mailto:', 'tel:']),
|
|
142
|
+
policies: boundedUriList(source.policies, ['https:']),
|
|
143
|
+
encryption: boundedUriList(source.encryption, ['https:', 'dns:', 'openpgp4fpr:']),
|
|
144
|
+
canonical: boundedUriList(source.canonical, ['https:']),
|
|
145
|
+
preferredLanguages: boundedStringList(source.preferredLanguages, 10, 40),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function sslblSource(value) {
|
|
149
|
+
const source = recordOrNull(value);
|
|
150
|
+
if (!source
|
|
151
|
+
|| source.sslblVersion !== 1
|
|
152
|
+
|| source.source !== 'sslbl'
|
|
153
|
+
|| typeof source.status !== 'string'
|
|
154
|
+
|| !SSLBL_STATES.has(source.status)
|
|
155
|
+
|| typeof source.verdict !== 'string'
|
|
156
|
+
|| !SSLBL_VERDICTS.has(source.verdict)) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const snapshot = recordOrNull(source.snapshot);
|
|
160
|
+
const rawFingerprint = boundedString(source.fingerprintSha1, 40);
|
|
161
|
+
const fingerprint = rawFingerprint && /^[a-f0-9]{40}$/u.test(rawFingerprint)
|
|
162
|
+
? rawFingerprint
|
|
163
|
+
: null;
|
|
164
|
+
const validState = (source.verdict === 'not_listed'
|
|
165
|
+
&& source.status === 'success'
|
|
166
|
+
&& source.complete === true)
|
|
167
|
+
|| (source.verdict === 'listed'
|
|
168
|
+
&& (source.status === 'success' || source.status === 'stale')
|
|
169
|
+
&& source.complete === (source.status === 'success'))
|
|
170
|
+
|| (source.verdict === 'inconclusive'
|
|
171
|
+
&& (source.status === 'stale' || source.status === 'unavailable')
|
|
172
|
+
&& source.complete === false);
|
|
173
|
+
if (!validState)
|
|
174
|
+
return null;
|
|
175
|
+
return {
|
|
176
|
+
sslblVersion: 1,
|
|
177
|
+
source: 'sslbl',
|
|
178
|
+
status: source.status,
|
|
179
|
+
verdict: source.verdict,
|
|
180
|
+
complete: source.complete === true,
|
|
181
|
+
observedAt: boundedTimestamp(source.observedAt),
|
|
182
|
+
fingerprintSha1: fingerprint,
|
|
183
|
+
referenceUrl: boundedSslblReferenceUrl(source.referenceUrl, fingerprint, source.verdict),
|
|
184
|
+
snapshot: snapshot ? {
|
|
185
|
+
sourceUpdatedAt: boundedTimestamp(snapshot.sourceUpdatedAt),
|
|
186
|
+
generatedAt: boundedTimestamp(snapshot.generatedAt),
|
|
187
|
+
ageSeconds: boundedInteger(snapshot.ageSeconds, 31_536_000),
|
|
188
|
+
entryCount: boundedInteger(snapshot.entryCount, 50_000),
|
|
189
|
+
digestSha256: /^[a-f0-9]{64}$/u.test(String(snapshot.digestSha256 || ''))
|
|
190
|
+
? snapshot.digestSha256
|
|
191
|
+
: null,
|
|
192
|
+
} : null,
|
|
193
|
+
detail: boundedString(source.detail, 300),
|
|
194
|
+
limitations: boundedStringList(source.limitations, 10, 300),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function networkAttempt(value) {
|
|
198
|
+
const attempt = recordOrNull(value) || {};
|
|
199
|
+
const endpoint = boundedEndpoint(attempt.endpoint);
|
|
200
|
+
return {
|
|
201
|
+
endpoint,
|
|
202
|
+
transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
203
|
+
status: boundedHttpStatus(attempt.status),
|
|
204
|
+
outcome: boundedString(attempt.outcome, 40),
|
|
205
|
+
detail: boundedString(attempt.detail, 240),
|
|
206
|
+
selected: attempt.selected === true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function networkSource(value) {
|
|
210
|
+
const source = recordOrNull(value);
|
|
211
|
+
if (!source
|
|
212
|
+
|| source.contextVersion !== 1
|
|
213
|
+
|| typeof source.status !== 'string'
|
|
214
|
+
|| !NETWORK_CONTEXT_STATUSES.has(source.status))
|
|
215
|
+
return null;
|
|
216
|
+
const endpoint = recordOrNull(source.endpoint);
|
|
217
|
+
const rdap = recordOrNull(source.rdap);
|
|
218
|
+
const network = recordOrNull(source.network);
|
|
219
|
+
const diagnostics = recordOrNull(source.diagnostics);
|
|
220
|
+
const rdapEndpoint = boundedEndpoint(rdap?.endpoint);
|
|
221
|
+
return {
|
|
222
|
+
contextVersion: 1,
|
|
223
|
+
version: source.version === 1 ? 1 : null,
|
|
224
|
+
status: source.status,
|
|
225
|
+
observedAt: boundedTimestamp(source.observedAt),
|
|
226
|
+
scanMode: source.scanMode === 'deep' ? 'deep' : null,
|
|
227
|
+
source: source.source === 'ip_rdap' ? 'ip_rdap' : null,
|
|
228
|
+
durationMs: boundedInteger(source.durationMs, 120_000),
|
|
229
|
+
complete: source.complete === true,
|
|
230
|
+
truncated: source.truncated === true,
|
|
231
|
+
limitations: boundedStringList(source.limitations, 10, 300),
|
|
232
|
+
diagnostics: diagnostics ? {
|
|
233
|
+
requestCount: boundedInteger(diagnostics.requestCount, 1),
|
|
234
|
+
addressSource: typeof diagnostics.addressSource === 'string'
|
|
235
|
+
&& NETWORK_ADDRESS_SOURCES.has(diagnostics.addressSource)
|
|
236
|
+
? diagnostics.addressSource
|
|
237
|
+
: null,
|
|
238
|
+
httpStatus: boundedHttpStatus(diagnostics.httpStatus),
|
|
239
|
+
cidrCount: boundedInteger(diagnostics.cidrCount, 16),
|
|
240
|
+
} : null,
|
|
241
|
+
detail: boundedString(source.detail, 300),
|
|
242
|
+
endpoint: endpoint ? {
|
|
243
|
+
address: boundedString(endpoint.address, 64),
|
|
244
|
+
family: endpoint.family === 4 || endpoint.family === 6 ? endpoint.family : null,
|
|
245
|
+
selectedFrom: typeof endpoint.selectedFrom === 'string'
|
|
246
|
+
&& NETWORK_ADDRESS_SOURCES.has(endpoint.selectedFrom)
|
|
247
|
+
? endpoint.selectedFrom
|
|
248
|
+
: null,
|
|
249
|
+
} : null,
|
|
250
|
+
rdap: rdap ? {
|
|
251
|
+
endpoint: rdapEndpoint,
|
|
252
|
+
transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
253
|
+
httpStatus: boundedHttpStatus(rdap.httpStatus),
|
|
254
|
+
fetchedAt: boundedTimestamp(rdap.fetchedAt),
|
|
255
|
+
attempts: (Array.isArray(rdap.attempts) ? rdap.attempts : []).slice(0, 3).map(networkAttempt),
|
|
256
|
+
} : null,
|
|
257
|
+
network: network ? {
|
|
258
|
+
handle: boundedString(network.handle, 300),
|
|
259
|
+
name: boundedString(network.name, 300),
|
|
260
|
+
holder: boundedString(network.holder, 300),
|
|
261
|
+
cidrs: boundedStringList(network.cidrs, 16, 96),
|
|
262
|
+
startAddress: boundedString(network.startAddress, 64),
|
|
263
|
+
endAddress: boundedString(network.endAddress, 64),
|
|
264
|
+
country: /^[a-z]{2}$/i.test(String(network.country || '')) ? String(network.country).toUpperCase() : null,
|
|
265
|
+
networkType: boundedString(network.networkType, 160),
|
|
266
|
+
databaseUpdatedAt: boundedTimestamp(network.databaseUpdatedAt),
|
|
267
|
+
} : null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function reverseDnsSource(value) {
|
|
271
|
+
const source = recordOrNull(value);
|
|
272
|
+
if (!source
|
|
273
|
+
|| source.version !== 1
|
|
274
|
+
|| source.source !== 'reverse_dns'
|
|
275
|
+
|| typeof source.status !== 'string'
|
|
276
|
+
|| !REVERSE_DNS_STATUSES.has(source.status)) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const records = recordOrNull(source.records);
|
|
280
|
+
const diagnostics = recordOrNull(source.diagnostics);
|
|
281
|
+
const ptrDiagnostics = recordOrNull(diagnostics?.ptr);
|
|
282
|
+
const ptr = [...new Set((Array.isArray(records?.ptr) ? records.ptr : [])
|
|
283
|
+
.slice(0, 8)
|
|
284
|
+
.map(boundedHostname)
|
|
285
|
+
.filter((item) => item !== null))].sort();
|
|
286
|
+
return {
|
|
287
|
+
version: 1,
|
|
288
|
+
status: source.status,
|
|
289
|
+
observedAt: boundedTimestamp(source.observedAt),
|
|
290
|
+
scanMode: source.scanMode === 'deep' ? 'deep' : null,
|
|
291
|
+
source: 'reverse_dns',
|
|
292
|
+
durationMs: boundedInteger(source.durationMs, 120_000),
|
|
293
|
+
complete: source.complete === true,
|
|
294
|
+
truncated: source.truncated === true,
|
|
295
|
+
limitations: boundedStringList(source.limitations, 10, 300),
|
|
296
|
+
diagnostics: ptrDiagnostics ? {
|
|
297
|
+
ptr: {
|
|
298
|
+
status: boundedString(ptrDiagnostics.status, 40),
|
|
299
|
+
error: boundedString(ptrDiagnostics.error, 180),
|
|
300
|
+
truncated: ptrDiagnostics.truncated === true,
|
|
301
|
+
discarded: boundedInteger(ptrDiagnostics.discarded, 1000),
|
|
302
|
+
},
|
|
303
|
+
} : null,
|
|
304
|
+
records: { ptr },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function rdapSource(rdap) {
|
|
308
|
+
const source = recordOrNull(rdap) || {};
|
|
309
|
+
if (source.error)
|
|
310
|
+
return {
|
|
311
|
+
status: 'error',
|
|
312
|
+
error: String(source.error),
|
|
313
|
+
attempts: cloneJson(source.attempts || []),
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
status: source.upstreamStatus === 404 ? 'not_found' : 'success',
|
|
317
|
+
endpoint: source.rdapServer || null,
|
|
318
|
+
transportSecurity: source.transportSecurity || null,
|
|
319
|
+
httpStatus: source.upstreamStatus ?? null,
|
|
320
|
+
fetchedAt: source.fetchedAt || null,
|
|
321
|
+
attempts: cloneJson(source.attempts || []),
|
|
322
|
+
parsed: cloneJson(source.parsed),
|
|
323
|
+
raw: cloneJson(source.data),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function whoisSource(whois) {
|
|
327
|
+
const source = recordOrNull(whois) || {};
|
|
328
|
+
if (source.error)
|
|
329
|
+
return { status: 'error', error: String(source.error) };
|
|
330
|
+
const parsed = recordOrNull(source.parsed);
|
|
331
|
+
const chain = Array.isArray(source.chain) ? source.chain : [];
|
|
332
|
+
const firstHop = recordOrNull(chain[0]);
|
|
333
|
+
return {
|
|
334
|
+
status: parsed?.chainStatus || 'unknown',
|
|
335
|
+
queriedAt: firstHop?.queriedAt || null,
|
|
336
|
+
authoritativeHop: parsed?.authoritativeHop || null,
|
|
337
|
+
failedHop: parsed?.failedHop || null,
|
|
338
|
+
conflictingHop: parsed?.conflictingHop || null,
|
|
339
|
+
parsed: cloneJson(parsed),
|
|
340
|
+
chain: cloneJson(chain),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function registrarPublicationComparison(body, registryParsed) {
|
|
344
|
+
const rdap = recordOrNull(body.rdap);
|
|
345
|
+
const registrar = recordOrNull(rdap?.registrarRdap);
|
|
346
|
+
const diagnostics = recordOrNull(body.diagnostics);
|
|
347
|
+
const rdapDiagnostics = recordOrNull(diagnostics?.rdap);
|
|
348
|
+
const registrarDiagnostics = recordOrNull(rdapDiagnostics?.registrar);
|
|
349
|
+
if (!registrar && !registrarDiagnostics)
|
|
350
|
+
return null;
|
|
351
|
+
const reportedStatus = registrar?.status ?? registrarDiagnostics?.status;
|
|
352
|
+
const parsed = recordOrNull(registrar?.parsed);
|
|
353
|
+
const registrarStatus = typeof reportedStatus === 'string' && REGISTRAR_RDAP_STATUSES.has(reportedStatus)
|
|
354
|
+
? (reportedStatus === 'success' && !parsed ? 'partial' : reportedStatus)
|
|
355
|
+
: 'error';
|
|
356
|
+
return compareRdapPublications(registryParsed, parsed, {
|
|
357
|
+
registryStatus: rdapDiagnostics?.status,
|
|
358
|
+
registrarStatus,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
export function buildLookupEvidence(response, options = {}) {
|
|
362
|
+
const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
|
|
363
|
+
const body = recordOrNull(response) || {};
|
|
364
|
+
const rdap = recordOrNull(body.rdap);
|
|
365
|
+
const whois = recordOrNull(body.whois);
|
|
366
|
+
const diagnostics = recordOrNull(body.diagnostics);
|
|
367
|
+
const rdapDiagnostics = recordOrNull(diagnostics?.rdap);
|
|
368
|
+
const whoisDiagnostics = recordOrNull(diagnostics?.whois);
|
|
369
|
+
const rdapParsed = rdap && !rdap.error ? recordOrNull(rdap.parsed) : null;
|
|
370
|
+
const whoisParsed = whois && !whois.error ? recordOrNull(whois.parsed) : null;
|
|
371
|
+
const registrarRdap = recordOrNull(rdap?.registrarRdap);
|
|
372
|
+
const registrarRdapParsed = recordOrNull(registrarRdap?.parsed);
|
|
373
|
+
// Rebuild the pure interpretation from bounded source fields instead of
|
|
374
|
+
// trusting a supplied derived object. This adds no collection or network
|
|
375
|
+
// work and lets saved or fast responses carry current source-health-aware
|
|
376
|
+
// interpretation in a deliberate analyst-created export.
|
|
377
|
+
const registryInsights = buildRegistryInsights({
|
|
378
|
+
rdapParsed,
|
|
379
|
+
rdapStatus: rdapDiagnostics?.status,
|
|
380
|
+
rdapFetchedAt: rdap?.fetchedAt,
|
|
381
|
+
whoisParsed,
|
|
382
|
+
whoisStatus: whoisDiagnostics?.status,
|
|
383
|
+
whoisQueriedAt: recordOrNull(Array.isArray(whois?.chain) ? whois.chain[0] : null)?.queriedAt,
|
|
384
|
+
registrarRdapParsed,
|
|
385
|
+
registrarRdapStatus: registrarRdap?.status ?? recordOrNull(rdapDiagnostics?.registrar)?.status,
|
|
386
|
+
registrarRdapFetchedAt: registrarRdap?.fetchedAt,
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
schema: LOOKUP_EVIDENCE_SCHEMA,
|
|
390
|
+
schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
391
|
+
generatedAt,
|
|
392
|
+
application: { name: 'WHOISleuth' },
|
|
393
|
+
query: {
|
|
394
|
+
submitted: body.query || null,
|
|
395
|
+
type: body.type || null,
|
|
396
|
+
inputHostname: body.inputHostname || null,
|
|
397
|
+
registrableDomain: body.registrableDomain || null,
|
|
398
|
+
isSubdomain: body.isSubdomain === true,
|
|
399
|
+
},
|
|
400
|
+
diagnostics: cloneJson(body.diagnostics),
|
|
401
|
+
sources: {
|
|
402
|
+
rdap: rdapSource(rdap),
|
|
403
|
+
whois: whoisSource(whois),
|
|
404
|
+
reverseDns: reverseDnsSource(body.reverseDns),
|
|
405
|
+
network: networkSource(body.networkContext),
|
|
406
|
+
securityTxt: securityTxtSource(body.securityTxt),
|
|
407
|
+
sslbl: sslblSource(body.sslbl),
|
|
408
|
+
},
|
|
409
|
+
analysis: {
|
|
410
|
+
availability: cloneJson(body.availability),
|
|
411
|
+
idn: cloneJson(idnAnalysis),
|
|
412
|
+
registryInsights,
|
|
413
|
+
registryComparison: compareRegistrySources(rdapParsed, whoisParsed, {
|
|
414
|
+
rdapStatus: rdapDiagnostics?.status,
|
|
415
|
+
whoisStatus: whoisDiagnostics?.status,
|
|
416
|
+
}),
|
|
417
|
+
registrarPublicationComparison: registrarPublicationComparison(body, rdapParsed),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
export function evidenceFilename(response, now = Date.now()) {
|
|
422
|
+
const record = recordOrNull(response);
|
|
423
|
+
const rawTarget = record?.registrableDomain || record?.inputHostname || record?.query || 'lookup';
|
|
424
|
+
const target = String(rawTarget)
|
|
425
|
+
.toLowerCase()
|
|
426
|
+
.replace(/[^a-z0-9.-]+/g, '-')
|
|
427
|
+
.replace(/^-+|-+$/g, '')
|
|
428
|
+
.slice(0, 80) || 'lookup';
|
|
429
|
+
const timestamp = new Date(now).toISOString().replace(/[:.]/g, '-');
|
|
430
|
+
return `whoisleuth-evidence-${target}-${timestamp}.json`;
|
|
431
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { MAX_REPORT_LIST_ITEMS, MAX_REPORT_VALUE_LENGTH, buildLookupEvidenceReport, cleanReportText, } from "./evidence-report.mjs";
|
|
2
|
+
function escapeMarkdownValue(value, fallback = 'Not reported') {
|
|
3
|
+
return cleanReportText(value, fallback)
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/([\\`*_{}\[\]()#+\-.!|=~])/g, '\\$1')
|
|
8
|
+
.replace(/:/g, '\\:')
|
|
9
|
+
.replace(/@/g, '\\@');
|
|
10
|
+
}
|
|
11
|
+
function appendFields(lines, fields) {
|
|
12
|
+
for (const field of fields)
|
|
13
|
+
lines.push(`- **${field.label}:** ${escapeMarkdownValue(field.value)}`);
|
|
14
|
+
}
|
|
15
|
+
function appendGroups(lines, groups) {
|
|
16
|
+
for (const group of groups) {
|
|
17
|
+
lines.push('', `### ${group.title}`);
|
|
18
|
+
appendFields(lines, group.fields);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function formatLookupEvidenceMarkdown(document, options = {}) {
|
|
22
|
+
const report = buildLookupEvidenceReport(document, options);
|
|
23
|
+
const lines = [
|
|
24
|
+
`# Lookup evidence report — ${escapeMarkdownValue(report.title)}`,
|
|
25
|
+
'', `> ${report.notice}`, '',
|
|
26
|
+
];
|
|
27
|
+
appendFields(lines, report.metadata);
|
|
28
|
+
lines.push('', '## Query');
|
|
29
|
+
appendFields(lines, report.query);
|
|
30
|
+
lines.push('', '## Assessment');
|
|
31
|
+
appendFields(lines, report.assessment);
|
|
32
|
+
lines.push('', '## Registry sources');
|
|
33
|
+
appendGroups(lines, report.registryGroups);
|
|
34
|
+
if (report.registryInterpretation.length) {
|
|
35
|
+
lines.push('', '## Registry interpretation');
|
|
36
|
+
appendFields(lines, report.registryInterpretation);
|
|
37
|
+
}
|
|
38
|
+
lines.push('', '## Registry-source comparison');
|
|
39
|
+
appendFields(lines, report.comparison.health);
|
|
40
|
+
if (!report.comparison.fields.length) {
|
|
41
|
+
lines.push('- No comparable normalized fields were published.');
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
for (const field of report.comparison.fields) {
|
|
45
|
+
lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** RDAP ${escapeMarkdownValue(field.rdap)}; WHOIS ${escapeMarkdownValue(field.whois)}`);
|
|
46
|
+
}
|
|
47
|
+
if (report.comparison.omitted)
|
|
48
|
+
lines.push(`- ${report.comparison.omitted} additional comparison fields omitted.`);
|
|
49
|
+
}
|
|
50
|
+
if (report.registrarComparison.fields.length) {
|
|
51
|
+
lines.push('', '## Registry / registrar RDAP comparison');
|
|
52
|
+
appendFields(lines, report.registrarComparison.health);
|
|
53
|
+
for (const field of report.registrarComparison.fields) {
|
|
54
|
+
lines.push(`- **${escapeMarkdownValue(field.label)} — ${escapeMarkdownValue(field.status)}:** Registry RDAP ${escapeMarkdownValue(field.registry)}; registrar RDAP ${escapeMarkdownValue(field.registrar)}`);
|
|
55
|
+
}
|
|
56
|
+
if (report.registrarComparison.omitted)
|
|
57
|
+
lines.push(`- ${report.registrarComparison.omitted} additional comparison fields omitted.`);
|
|
58
|
+
}
|
|
59
|
+
lines.push('', '## Network evidence');
|
|
60
|
+
appendGroups(lines, report.networkGroups);
|
|
61
|
+
lines.push('', '## Collection diagnostics');
|
|
62
|
+
appendFields(lines, report.diagnostics);
|
|
63
|
+
lines.push('', '## Limitations', '');
|
|
64
|
+
for (const limitation of report.limitations)
|
|
65
|
+
lines.push(`- ${limitation}`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
const MAX_MARKDOWN_LIST_ITEMS = MAX_REPORT_LIST_ITEMS;
|
|
70
|
+
const MAX_MARKDOWN_VALUE_LENGTH = MAX_REPORT_VALUE_LENGTH;
|
|
71
|
+
export { MAX_MARKDOWN_LIST_ITEMS, MAX_MARKDOWN_VALUE_LENGTH, escapeMarkdownValue, formatLookupEvidenceMarkdown, };
|