1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'SecurityTxtAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const MAX_BODY_BYTES = 256 * 1024;
9
10function isPrivateIPv4(ip) {
11 const parts = ip.split('.').map(Number);
12 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
13 const [a, b] = parts;
14 return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254)
15 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
16}
17
18function isPrivateIPv6(ip) {
19 const value = ip.toLowerCase();
20 return value === '::1' || value.startsWith('fc') || value.startsWith('fd') || value.startsWith('fe80:');
21}
22
23export async function normalizeAndValidateUrl(rawUrl) {
24 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
25 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
26 throw new Error('Only HTTP and HTTPS URLs are supported');
27 }
28 const url = new URL(/^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`);
29 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
30 if (!url.hostname || url.username || url.password) throw new Error('URL credentials are not allowed');
31
32 const literalType = net.isIP(url.hostname);
33 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
34 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
35
36 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
37 for (const record of records) {
38 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
39 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
40 }
41 return url;
42}
43
44async function fetchText(url, timeoutSeconds, redirectsRemaining = 3) {
45 await normalizeAndValidateUrl(url.href);
46 const controller = new AbortController();
47 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
48 try {
49 const response = await fetch(url, {
50 redirect: 'manual',
51 signal: controller.signal,
52 headers: { 'user-agent': USER_AGENT, accept: 'text/plain,*/*;q=0.1' },
53 });
54 if ([301, 302, 303, 307, 308].includes(response.status)) {
55 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
56 const location = response.headers.get('location');
57 if (!location) throw new Error('Redirect without Location header');
58 return fetchText(new URL(location, url.href), timeoutSeconds, redirectsRemaining - 1);
59 }
60 const text = await response.text();
61 return {
62 ok: response.ok,
63 status: response.status,
64 url: response.url || url.href,
65 text: text.slice(0, MAX_BODY_BYTES),
66 contentType: response.headers.get('content-type') || '',
67 error: null,
68 };
69 } catch (error) {
70 return { ok: false, status: null, url: url.href, text: '', contentType: '', error: error.message };
71 } finally {
72 clearTimeout(timeout);
73 }
74}
75
76export function parseSecurityTxt(text) {
77 const fields = {};
78 const unknownFields = [];
79 for (const rawLine of text.split(/\r?\n/)) {
80 const line = rawLine.trim();
81 if (!line || line.startsWith('#')) continue;
82 const match = /^([A-Za-z][A-Za-z-]*):\s*(.+)$/.exec(line);
83 if (!match) continue;
84 const key = match[1].toLowerCase();
85 const value = match[2].trim();
86 if (!['contact', 'expires', 'encryption', 'acknowledgments', 'policy', 'hiring', 'canonical', 'preferred-languages'].includes(key)) unknownFields.push(match[1]);
87 fields[key] = [...(fields[key] || []), value];
88 }
89 return { fields, unknownFields };
90}
91
92function daysUntil(dateText) {
93 const time = Date.parse(dateText);
94 if (Number.isNaN(time)) return null;
95 return Math.ceil((time - Date.now()) / 86400000);
96}
97
98export function analyzeSecurityTxt(parsed, fileUrl, siteOrigin) {
99 const { fields, unknownFields } = parsed;
100 const findings = [];
101 const recommendations = [];
102 let score = 0;
103
104 const add = (name, status, points, recommendation = null) => {
105 findings.push({ name, status, points, recommendation });
106 if (status === 'pass') score += points;
107 if (status === 'warn') score += Math.floor(points / 2);
108 if (recommendation) recommendations.push(recommendation);
109 };
110
111 add('File found', 'pass', 20);
112 add('Preferred path', fileUrl.endsWith('/.well-known/security.txt') ? 'pass' : 'warn', 15,
113 fileUrl.endsWith('/.well-known/security.txt') ? null : 'Host security.txt at /.well-known/security.txt.');
114
115 const contacts = fields.contact || [];
116 add('Contact field', contacts.length ? 'pass' : 'fail', 20, contacts.length ? null : 'Add at least one Contact field.');
117 add('HTTPS or mail contact', contacts.some((v) => /^https:|^mailto:/i.test(v)) ? 'pass' : 'warn', 10,
118 'Use an HTTPS form URL or mailto address for Contact.');
119
120 const expires = fields.expires?.[0];
121 const days = expires ? daysUntil(expires) : null;
122 add('Expires field', expires ? 'pass' : 'fail', 15, expires ? null : 'Add an Expires field with an ISO 8601 timestamp.');
123 add('Not expired', days === null ? 'warn' : days > 0 ? 'pass' : 'fail', 10,
124 days === null ? 'Use a valid ISO 8601 Expires timestamp.' : days > 0 ? null : 'Update Expires to a future timestamp.');
125
126 const canonical = fields.canonical || [];
127 add('Canonical field', canonical.includes(fileUrl) || canonical.includes(`${siteOrigin}/.well-known/security.txt`) ? 'pass' : 'warn', 5,
128 'Add a Canonical field matching the security.txt URL.');
129 add('Policy field', fields.policy?.length ? 'pass' : 'warn', 5, fields.policy?.length ? null : 'Add a Policy URL for vulnerability disclosure rules.');
130
131 return { score: Math.min(score, 100), grade: gradeFromScore(score), findings, recommendations, fields, unknownFields };
132}
133
134export function gradeFromScore(score) {
135 if (score >= 85) return 'A';
136 if (score >= 70) return 'B';
137 if (score >= 55) return 'C';
138 if (score >= 40) return 'D';
139 return 'F';
140}
141
142export async function auditSecurityTxt(input) {
143 const startUrl = await normalizeAndValidateUrl(input.startUrl);
144 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
145 const siteOrigin = startUrl.origin;
146 const candidates = [new URL('/.well-known/security.txt', siteOrigin), new URL('/security.txt', siteOrigin)];
147 const attempts = [];
148
149 for (const candidate of candidates) {
150 const result = await fetchText(candidate, timeoutSeconds);
151 attempts.push({ url: result.url, status: result.status, ok: result.ok, error: result.error });
152 if (result.ok && /contact\s*:/i.test(result.text)) {
153 const parsed = parseSecurityTxt(result.text);
154 const analysis = analyzeSecurityTxt(parsed, result.url, siteOrigin);
155 return {
156 inputUrl: input.startUrl,
157 siteOrigin,
158 found: true,
159 fileUrl: result.url,
160 status: result.status,
161 contentType: result.contentType,
162 score: analysis.score,
163 grade: analysis.grade,
164 checkedAt: new Date().toISOString(),
165 fields: analysis.fields,
166 unknownFields: analysis.unknownFields,
167 findings: analysis.findings,
168 recommendations: analysis.recommendations,
169 attempts,
170 };
171 }
172 }
173
174 return {
175 inputUrl: input.startUrl,
176 siteOrigin,
177 found: false,
178 fileUrl: '',
179 status: attempts[0]?.status || null,
180 score: 0,
181 grade: 'F',
182 checkedAt: new Date().toISOString(),
183 fields: {},
184 unknownFields: [],
185 findings: [{ name: 'File found', status: 'fail', points: 0, recommendation: 'Publish /.well-known/security.txt.' }],
186 recommendations: ['Publish /.well-known/security.txt with Contact and Expires fields.'],
187 attempts,
188 };
189}
190
191const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
192
193if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
194 await Actor.init();
195 try {
196 const result = await auditSecurityTxt((await Actor.getInput()) || {});
197 await Actor.pushData(result);
198 await Actor.setValue('OUTPUT', result);
199 Actor.log.info('security.txt audit complete', { siteOrigin: result.siteOrigin, found: result.found, score: result.score, grade: result.grade });
200 } finally {
201 await Actor.exit();
202 }
203}