@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,359 @@
|
|
|
1
|
+
// Optional RFC 9116 disclosure-contact collection for a single deep Lookup.
|
|
2
|
+
// The request is deliberately separate from homepage analysis: it uses the
|
|
3
|
+
// exact queried hostname, HTTPS only, and never contributes to availability or
|
|
4
|
+
// Risk. Published contact and policy values are normalized into a small,
|
|
5
|
+
// bounded record; the response body itself is discarded after parsing.
|
|
6
|
+
import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
|
|
7
|
+
import { createObservation } from "./observation.mjs";
|
|
8
|
+
const SECURITY_TXT_VERSION = 1;
|
|
9
|
+
const SECURITY_TXT_PATH = '/.well-known/security.txt';
|
|
10
|
+
const SECURITY_TXT_TIMEOUT_MS = 6_000;
|
|
11
|
+
const MAX_SECURITY_TXT_BYTES = 64 * 1024;
|
|
12
|
+
const MAX_SECURITY_TXT_REDIRECTS = 3;
|
|
13
|
+
const MAX_SECURITY_TXT_LINES = 256;
|
|
14
|
+
const MAX_SECURITY_TXT_LINE_LENGTH = 2_048;
|
|
15
|
+
const MAX_SECURITY_TXT_VALUES = 10;
|
|
16
|
+
const MAX_SECURITY_TXT_URL_LENGTH = 2_048;
|
|
17
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
18
|
+
const URI_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
19
|
+
const FIELD_RE = /^([A-Za-z][A-Za-z-]*):[ \t]*(.*)$/u;
|
|
20
|
+
const LANGUAGE_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/iu;
|
|
21
|
+
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
|
|
22
|
+
const CONTACT_PROTOCOLS = new Set(['https:', 'mailto:', 'tel:']);
|
|
23
|
+
const HTTPS_PROTOCOLS = new Set(['https:']);
|
|
24
|
+
const ENCRYPTION_PROTOCOLS = new Set(['https:', 'dns:', 'openpgp4fpr:']);
|
|
25
|
+
function boundedDetail(value, fallback) {
|
|
26
|
+
if (typeof value !== 'string')
|
|
27
|
+
return fallback;
|
|
28
|
+
return value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 240) || fallback;
|
|
29
|
+
}
|
|
30
|
+
function canonicalHostname(value) {
|
|
31
|
+
if (typeof value !== 'string' || !value || value.length > 253 || URI_CONTROL_CHARACTER_RE.test(value))
|
|
32
|
+
return null;
|
|
33
|
+
const raw = value.trim().toLowerCase();
|
|
34
|
+
if (!/^[a-z0-9.-]+$/iu.test(raw) || raw.startsWith('.') || raw.endsWith('.') || raw.includes('..'))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(`https://${raw}`);
|
|
38
|
+
return url.hostname === raw && url.hostname.includes('.') ? url.hostname : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizedPublishedUri(value, schemes) {
|
|
45
|
+
if (!value || value.length > MAX_SECURITY_TXT_URL_LENGTH || URI_CONTROL_CHARACTER_RE.test(value))
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(value.trim());
|
|
49
|
+
if (!schemes.has(url.protocol) || url.username || url.password)
|
|
50
|
+
return null;
|
|
51
|
+
if (url.protocol === 'https:' && !url.hostname)
|
|
52
|
+
return null;
|
|
53
|
+
url.search = '';
|
|
54
|
+
url.hash = '';
|
|
55
|
+
return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function normalizedHttpsUrl(value) {
|
|
62
|
+
return normalizedPublishedUri(value, HTTPS_PROTOCOLS);
|
|
63
|
+
}
|
|
64
|
+
function eligibleHttpsFetchUrl(value) {
|
|
65
|
+
if (!value || value.length > MAX_SECURITY_TXT_URL_LENGTH || URI_CONTROL_CHARACTER_RE.test(value))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(value.trim());
|
|
69
|
+
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.port)
|
|
70
|
+
return null;
|
|
71
|
+
url.hash = '';
|
|
72
|
+
return url.toString().slice(0, MAX_SECURITY_TXT_URL_LENGTH);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function uniqueBounded(values) {
|
|
79
|
+
const unique = [...new Set(values)];
|
|
80
|
+
return { values: unique.slice(0, MAX_SECURITY_TXT_VALUES), truncated: unique.length > MAX_SECURITY_TXT_VALUES };
|
|
81
|
+
}
|
|
82
|
+
function clearSignedPayload(text) {
|
|
83
|
+
if (!text.startsWith('-----BEGIN PGP SIGNED MESSAGE-----'))
|
|
84
|
+
return { text, signed: false };
|
|
85
|
+
const lines = text.replace(/\r\n?/gu, '\n').split('\n');
|
|
86
|
+
const bodyStart = lines.findIndex((line, index) => index > 0 && line === '');
|
|
87
|
+
const signatureStart = lines.findIndex((line) => line === '-----BEGIN PGP SIGNATURE-----');
|
|
88
|
+
if (bodyStart < 0 || signatureStart <= bodyStart)
|
|
89
|
+
return { text: '', signed: true };
|
|
90
|
+
return {
|
|
91
|
+
text: lines.slice(bodyStart + 1, signatureStart).map((line) => line.startsWith('- ') ? line.slice(2) : line).join('\n'),
|
|
92
|
+
signed: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function parseSecurityTxt(text, options = {}) {
|
|
96
|
+
const observedAt = new Date(options.now ?? Date.now()).toISOString();
|
|
97
|
+
if (typeof text !== 'string' || text.includes('\ufffd') || CONTROL_CHARACTER_RE.test(text)) {
|
|
98
|
+
return {
|
|
99
|
+
securityTxtVersion: SECURITY_TXT_VERSION,
|
|
100
|
+
state: 'malformed',
|
|
101
|
+
detail: 'The response was not valid bounded UTF-8 text.',
|
|
102
|
+
contacts: [], policies: [], encryption: [], canonical: [], preferredLanguages: [],
|
|
103
|
+
expiresAt: null, signed: false, canonicalMatches: null,
|
|
104
|
+
...createObservation({ status: 'error', observedAt, scanMode: 'deep', source: 'security_txt', complete: false }),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const signedPayload = clearSignedPayload(text);
|
|
108
|
+
const lines = signedPayload.text.replace(/\r\n?/gu, '\n').split('\n');
|
|
109
|
+
let malformedCount = 0;
|
|
110
|
+
let ignoredCount = 0;
|
|
111
|
+
let valuesTruncated = options.truncated === true || lines.length > MAX_SECURITY_TXT_LINES;
|
|
112
|
+
const contacts = [];
|
|
113
|
+
const policies = [];
|
|
114
|
+
const encryption = [];
|
|
115
|
+
const canonical = [];
|
|
116
|
+
const preferredLanguages = [];
|
|
117
|
+
const expires = [];
|
|
118
|
+
for (const rawLine of lines.slice(0, MAX_SECURITY_TXT_LINES)) {
|
|
119
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith('#'))
|
|
120
|
+
continue;
|
|
121
|
+
if (rawLine.length > MAX_SECURITY_TXT_LINE_LENGTH) {
|
|
122
|
+
malformedCount += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const match = rawLine.match(FIELD_RE);
|
|
126
|
+
if (!match) {
|
|
127
|
+
malformedCount += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const field = match[1]?.toLowerCase();
|
|
131
|
+
const value = match[2]?.trim();
|
|
132
|
+
if (!field || value === undefined) {
|
|
133
|
+
malformedCount += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let normalized = null;
|
|
137
|
+
if (field === 'contact') {
|
|
138
|
+
normalized = normalizedPublishedUri(value, CONTACT_PROTOCOLS);
|
|
139
|
+
if (normalized)
|
|
140
|
+
contacts.push(normalized);
|
|
141
|
+
else
|
|
142
|
+
malformedCount += 1;
|
|
143
|
+
}
|
|
144
|
+
else if (field === 'policy') {
|
|
145
|
+
normalized = normalizedHttpsUrl(value);
|
|
146
|
+
if (normalized)
|
|
147
|
+
policies.push(normalized);
|
|
148
|
+
else
|
|
149
|
+
malformedCount += 1;
|
|
150
|
+
}
|
|
151
|
+
else if (field === 'encryption') {
|
|
152
|
+
normalized = normalizedPublishedUri(value, ENCRYPTION_PROTOCOLS);
|
|
153
|
+
if (normalized)
|
|
154
|
+
encryption.push(normalized);
|
|
155
|
+
else
|
|
156
|
+
malformedCount += 1;
|
|
157
|
+
}
|
|
158
|
+
else if (field === 'canonical') {
|
|
159
|
+
normalized = normalizedHttpsUrl(value);
|
|
160
|
+
if (normalized)
|
|
161
|
+
canonical.push(normalized);
|
|
162
|
+
else
|
|
163
|
+
malformedCount += 1;
|
|
164
|
+
}
|
|
165
|
+
else if (field === 'preferred-languages') {
|
|
166
|
+
const languages = value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
167
|
+
const valid = languages.filter((item) => LANGUAGE_RE.test(item));
|
|
168
|
+
preferredLanguages.push(...valid);
|
|
169
|
+
malformedCount += languages.length - valid.length;
|
|
170
|
+
}
|
|
171
|
+
else if (field === 'expires') {
|
|
172
|
+
if (RFC3339_RE.test(value) && Number.isFinite(Date.parse(value)))
|
|
173
|
+
expires.push(new Date(value).toISOString());
|
|
174
|
+
else
|
|
175
|
+
malformedCount += 1;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
ignoredCount += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const boundedContacts = uniqueBounded(contacts);
|
|
182
|
+
const boundedPolicies = uniqueBounded(policies);
|
|
183
|
+
const boundedEncryption = uniqueBounded(encryption);
|
|
184
|
+
const boundedCanonical = uniqueBounded(canonical);
|
|
185
|
+
const boundedLanguages = uniqueBounded(preferredLanguages);
|
|
186
|
+
valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedLanguages]
|
|
187
|
+
.some((entry) => entry.truncated);
|
|
188
|
+
const expiresAt = expires.length === 1 ? expires[0] : null;
|
|
189
|
+
const finalUrl = options.finalUrl ? normalizedHttpsUrl(options.finalUrl) : null;
|
|
190
|
+
const canonicalMatches = boundedCanonical.values.length && finalUrl
|
|
191
|
+
? boundedCanonical.values.some((value) => value.toLowerCase() === finalUrl.toLowerCase())
|
|
192
|
+
: null;
|
|
193
|
+
const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
|
|
194
|
+
const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
|
|
195
|
+
let state = 'present';
|
|
196
|
+
if (requiredMalformed && !valuesTruncated)
|
|
197
|
+
state = 'malformed';
|
|
198
|
+
else if (stale)
|
|
199
|
+
state = 'stale';
|
|
200
|
+
else if (valuesTruncated || requiredMalformed || malformedCount > 0 || canonicalMatches === false)
|
|
201
|
+
state = 'partial';
|
|
202
|
+
const limitations = [
|
|
203
|
+
...(signedPayload.signed ? ['A cleartext signature was present but was not cryptographically verified.'] : []),
|
|
204
|
+
...(canonicalMatches === false ? ['The fetched URL was not listed by the file as a Canonical location.'] : []),
|
|
205
|
+
...(valuesTruncated ? ['The response or normalized field set exceeded a collection bound.'] : []),
|
|
206
|
+
];
|
|
207
|
+
const detail = state === 'malformed'
|
|
208
|
+
? 'The file did not contain at least one valid Contact field and exactly one valid Expires field.'
|
|
209
|
+
: state === 'stale'
|
|
210
|
+
? 'The published disclosure file has expired and may be stale.'
|
|
211
|
+
: state === 'partial'
|
|
212
|
+
? 'Disclosure fields were retained, but part of the publication was invalid, truncated, or inconsistent.'
|
|
213
|
+
: 'A current security disclosure file was published for this hostname.';
|
|
214
|
+
return {
|
|
215
|
+
securityTxtVersion: SECURITY_TXT_VERSION,
|
|
216
|
+
state,
|
|
217
|
+
detail,
|
|
218
|
+
contacts: boundedContacts.values,
|
|
219
|
+
policies: boundedPolicies.values,
|
|
220
|
+
encryption: boundedEncryption.values,
|
|
221
|
+
canonical: boundedCanonical.values,
|
|
222
|
+
preferredLanguages: boundedLanguages.values,
|
|
223
|
+
expiresAt,
|
|
224
|
+
signed: signedPayload.signed,
|
|
225
|
+
canonicalMatches,
|
|
226
|
+
...createObservation({
|
|
227
|
+
status: state === 'present' ? 'success' : state === 'malformed' ? 'error' : 'partial',
|
|
228
|
+
observedAt,
|
|
229
|
+
scanMode: 'deep',
|
|
230
|
+
source: 'security_txt',
|
|
231
|
+
complete: state === 'present',
|
|
232
|
+
truncated: valuesTruncated,
|
|
233
|
+
limitations,
|
|
234
|
+
diagnostics: { malformedCount, ignoredCount },
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function emptyResult(state, detail, input) {
|
|
239
|
+
const status = state === 'absent' ? 'not_found' : state === 'unsupported' ? 'unsupported' : 'error';
|
|
240
|
+
return {
|
|
241
|
+
securityTxtVersion: SECURITY_TXT_VERSION,
|
|
242
|
+
state,
|
|
243
|
+
detail,
|
|
244
|
+
requestedUrl: input.requestedUrl,
|
|
245
|
+
finalUrl: input.finalUrl ? normalizedHttpsUrl(input.finalUrl) : null,
|
|
246
|
+
httpStatus: input.httpStatus ?? null,
|
|
247
|
+
contacts: [], policies: [], encryption: [], canonical: [], preferredLanguages: [],
|
|
248
|
+
expiresAt: null, signed: false, canonicalMatches: null,
|
|
249
|
+
...createObservation({
|
|
250
|
+
status,
|
|
251
|
+
observedAt: input.observedAt,
|
|
252
|
+
scanMode: 'deep',
|
|
253
|
+
source: 'security_txt',
|
|
254
|
+
durationMs: input.durationMs,
|
|
255
|
+
complete: state === 'absent',
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function securityTxtUnavailable(hostname, error, timestamp = Date.now()) {
|
|
260
|
+
const normalizedHostname = canonicalHostname(hostname);
|
|
261
|
+
if (!normalizedHostname)
|
|
262
|
+
throw new Error('A valid domain hostname is required for security.txt collection.');
|
|
263
|
+
const observedAt = new Date(Number.isFinite(timestamp) ? timestamp : Date.now()).toISOString();
|
|
264
|
+
return emptyResult('unavailable', boundedDetail(error instanceof Error ? error.message : error, 'The disclosure request failed.'), {
|
|
265
|
+
observedAt,
|
|
266
|
+
requestedUrl: `https://${normalizedHostname}${SECURITY_TXT_PATH}`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async function collectSecurityTxt(hostname, dependencies = {}) {
|
|
270
|
+
const normalizedHostname = canonicalHostname(hostname);
|
|
271
|
+
if (!normalizedHostname)
|
|
272
|
+
throw new Error('A valid domain hostname is required for security.txt collection.');
|
|
273
|
+
const fetchDetailed = dependencies.fetchDetailed || safeFetchDetailed;
|
|
274
|
+
const readResponse = dependencies.readResponse || readTextCapped;
|
|
275
|
+
const now = dependencies.now || Date.now;
|
|
276
|
+
const startedAt = now();
|
|
277
|
+
const observedAt = new Date(startedAt).toISOString();
|
|
278
|
+
const requestedUrl = `https://${normalizedHostname}${SECURITY_TXT_PATH}`;
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const timer = setTimeout(() => controller.abort(), SECURITY_TXT_TIMEOUT_MS);
|
|
281
|
+
let currentUrl = requestedUrl;
|
|
282
|
+
let redirectCount = 0;
|
|
283
|
+
try {
|
|
284
|
+
while (true) {
|
|
285
|
+
const result = await fetchDetailed(currentUrl, {
|
|
286
|
+
headers: { accept: 'text/plain; charset=utf-8' },
|
|
287
|
+
signal: controller.signal,
|
|
288
|
+
}, { maxRedirects: 0 });
|
|
289
|
+
const response = result.response;
|
|
290
|
+
const redirectTarget = result.hops[0]?.location || null;
|
|
291
|
+
if (redirectTarget) {
|
|
292
|
+
await response.body?.cancel().catch(() => { });
|
|
293
|
+
if (redirectCount >= MAX_SECURITY_TXT_REDIRECTS) {
|
|
294
|
+
return emptyResult('unavailable', 'The disclosure file exceeded the redirect limit.', {
|
|
295
|
+
observedAt, requestedUrl, finalUrl: currentUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const next = eligibleHttpsFetchUrl(redirectTarget);
|
|
299
|
+
if (!next) {
|
|
300
|
+
return emptyResult('unavailable', 'The disclosure file redirected to an ineligible non-HTTPS location.', {
|
|
301
|
+
observedAt, requestedUrl, finalUrl: currentUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
currentUrl = next;
|
|
305
|
+
redirectCount += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const finalFetchUrl = eligibleHttpsFetchUrl(result.finalUrl);
|
|
309
|
+
if (!finalFetchUrl || finalFetchUrl !== currentUrl) {
|
|
310
|
+
await response.body?.cancel().catch(() => { });
|
|
311
|
+
return emptyResult('unavailable', 'The disclosure response reported an ineligible final location.', {
|
|
312
|
+
observedAt, requestedUrl, finalUrl: currentUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (response.status === 404 || response.status === 410) {
|
|
316
|
+
await response.body?.cancel().catch(() => { });
|
|
317
|
+
return emptyResult('absent', 'No security.txt file was published at the standardized location for this hostname.', {
|
|
318
|
+
observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
await response.body?.cancel().catch(() => { });
|
|
323
|
+
return emptyResult('unavailable', `The disclosure endpoint returned HTTP ${response.status}.`, {
|
|
324
|
+
observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const contentType = response.headers.get('content-type') || '';
|
|
328
|
+
const charset = contentType.match(/;\s*charset\s*=\s*"?([^;"\s]+)/iu)?.[1]?.toLowerCase() || null;
|
|
329
|
+
if (!/^text\/plain(?:\s*;|$)/iu.test(contentType) || (charset !== null && charset !== 'utf-8')) {
|
|
330
|
+
await response.body?.cancel().catch(() => { });
|
|
331
|
+
return emptyResult('unsupported', 'The standardized endpoint did not return a UTF-8 text/plain security.txt document.', {
|
|
332
|
+
observedAt, requestedUrl, finalUrl: result.finalUrl, httpStatus: response.status, durationMs: now() - startedAt,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const body = await readResponse(response, MAX_SECURITY_TXT_BYTES);
|
|
336
|
+
const parsed = parseSecurityTxt(body.text, { finalUrl: finalFetchUrl, truncated: body.truncated, now: startedAt });
|
|
337
|
+
return {
|
|
338
|
+
...parsed,
|
|
339
|
+
requestedUrl,
|
|
340
|
+
finalUrl: normalizedHttpsUrl(finalFetchUrl),
|
|
341
|
+
httpStatus: response.status,
|
|
342
|
+
redirectCount,
|
|
343
|
+
durationMs: Math.max(0, Math.min(120_000, Math.round(now() - startedAt))),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
const timedOut = controller.signal.aborted;
|
|
349
|
+
return emptyResult('unavailable', timedOut
|
|
350
|
+
? 'The disclosure request timed out.'
|
|
351
|
+
: boundedDetail(error instanceof Error ? error.message : error, 'The disclosure request failed.'), {
|
|
352
|
+
observedAt, requestedUrl, finalUrl: currentUrl, durationMs: now() - startedAt,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export { SECURITY_TXT_VERSION, SECURITY_TXT_PATH, SECURITY_TXT_TIMEOUT_MS, MAX_SECURITY_TXT_BYTES, MAX_SECURITY_TXT_REDIRECTS, parseSecurityTxt, collectSecurityTxt, securityTxtUnavailable, };
|