1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'SubresourceIntegrityAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
9const MAX_HTML_BYTES = 2 * 1024 * 1024;
10
11function isPrivateIPv4(ip) {
12 const parts = ip.split('.').map(Number);
13 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
14 const [a, b] = parts;
15 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
16 || a === 127 || a === 0 || (a === 169 && b === 254);
17}
18
19function isPrivateIPv6(ip) {
20 const v = ip.toLowerCase();
21 return v === '::1' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe80:');
22}
23
24export async function normalizeAndValidateUrl(rawUrl) {
25 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
26 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
27 const url = new URL(/^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`);
28 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
29 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
30 const literalType = net.isIP(url.hostname);
31 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
32 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
33 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
34 for (const record of records) {
35 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
36 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
37 }
38 return url;
39}
40
41async function fetchHtml(initialUrl, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
42 await normalizeAndValidateUrl(initialUrl.href);
43 const controller = new AbortController();
44 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
45 try {
46 const response = await fetch(initialUrl, {
47 redirect: 'manual',
48 signal: controller.signal,
49 headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' },
50 });
51 if ([301, 302, 303, 307, 308].includes(response.status)) {
52 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
53 const location = response.headers.get('location');
54 if (!location) throw new Error('Redirect without Location header');
55 return fetchHtml(new URL(location, initialUrl.href), timeoutSeconds, maxBytes, redirectsRemaining - 1);
56 }
57 const reader = response.body.getReader();
58 const chunks = [];
59 let total = 0;
60 let truncated = false;
61 while (true) {
62 const { done, value } = await reader.read();
63 if (done) break;
64 if (total + value.length > maxBytes) {
65 chunks.push(value.slice(0, Math.max(0, maxBytes - total)));
66 truncated = true;
67 break;
68 }
69 chunks.push(value);
70 total += value.length;
71 }
72 try { await reader.cancel(); } catch { }
73 return {
74 ok: response.ok,
75 status: response.status,
76 finalUrl: response.url || initialUrl.href,
77 https: (response.url || initialUrl.href).startsWith('https://'),
78 html: new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks)),
79 truncated,
80 error: null,
81 };
82 } catch (error) {
83 return { ok: false, status: null, finalUrl: initialUrl.href, https: initialUrl.protocol === 'https:', html: '', truncated: false, error: error.message };
84 } finally {
85 clearTimeout(timeout);
86 }
87}
88
89function decodeHtmlEntities(value) {
90 return String(value || '')
91 .replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'")
92 .replace(/</gi, '<').replace(/>/gi, '>')
93 .replace(/&#(\d+);/g, (_, c) => String.fromCodePoint(Number(c)))
94 .replace(/&#x([0-9a-f]+);/gi, (_, c) => String.fromCodePoint(parseInt(c, 16)))
95 .trim();
96}
97
98function parseAttributes(tag) {
99 const attrs = {};
100 const attrPattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|'[^']*'|[^\s"'>/]+))?/g;
101 for (const match of tag.matchAll(attrPattern)) {
102 const raw = match[2];
103 attrs[match[1].toLowerCase()] = raw === undefined ? '' : decodeHtmlEntities(raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : raw);
104 }
105 return attrs;
106}
107
108function matchTag(html, tagName) {
109
110 return [...html.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'))].map((m) => ({ raw: m[0], attrs: parseAttributes(m[0]) }));
111}
112
113function sameSite(a, b) {
114 const host = (u) => new URL(u).hostname.toLowerCase().split('.').slice(-2).join('.');
115 try { return host(a) === host(b); } catch { return false; }
116}
117
118function analyzeIntegrity(value) {
119 const tokens = String(value || '').trim().split(/\s+/).filter(Boolean);
120 if (!tokens.length) return { hasIntegrity: false, algorithms: [], validFormat: false, weakHash: false };
121 const algorithms = [];
122 let validFormat = true;
123 for (const token of tokens) {
124 const match = token.match(/^(sha256|sha384|sha512)-[A-Za-z0-9+/=]+$/);
125 if (!match) validFormat = false;
126 else algorithms.push(match[1]);
127 }
128 return { hasIntegrity: true, algorithms, validFormat, weakHash: algorithms.includes('sha256') && !algorithms.includes('sha384') && !algorithms.includes('sha512') };
129}
130
131export function analyzeSriResources(html, baseUrl) {
132 const tags = [
133 ...matchTag(html, 'script').filter((t) => t.attrs.src).map((t) => ({ tag: 'script', url: t.attrs.src, attrs: t.attrs })),
134 ...matchTag(html, 'link').filter((t) => /(^|\s)stylesheet(\s|$)/i.test(t.attrs.rel || '') && t.attrs.href).map((t) => ({ tag: 'stylesheet', url: t.attrs.href, attrs: t.attrs })),
135 ];
136 const resources = [];
137 const issues = [];
138 for (const tag of tags) {
139 let absoluteUrl;
140 try { absoluteUrl = new URL(tag.url, baseUrl).href; } catch { continue; }
141 const thirdParty = !sameSite(absoluteUrl, baseUrl);
142 const integrity = analyzeIntegrity(tag.attrs.integrity);
143 const crossorigin = Object.hasOwn(tag.attrs, 'crossorigin') ? (tag.attrs.crossorigin || 'anonymous') : null;
144 const itemIssues = [];
145 if (!absoluteUrl.startsWith('https://')) itemIssues.push('Resource is not loaded over HTTPS.');
146 if (thirdParty && !integrity.hasIntegrity) itemIssues.push('Third-party resource is missing Subresource Integrity.');
147 if (integrity.hasIntegrity && !integrity.validFormat) itemIssues.push('Integrity attribute has an unsupported or invalid hash format.');
148 if (integrity.weakHash) itemIssues.push('Integrity uses sha256 only; prefer sha384 or sha512.');
149 if (thirdParty && integrity.hasIntegrity && !crossorigin) itemIssues.push('Cross-origin SRI resource should include crossorigin="anonymous".');
150 const recommendation = itemIssues.length ? itemIssues[0] : 'No SRI issue detected.';
151 resources.push({ tag: tag.tag, url: absoluteUrl, thirdParty, hasIntegrity: integrity.hasIntegrity, algorithms: integrity.algorithms, validIntegrityFormat: integrity.validFormat, weakHash: integrity.weakHash, crossorigin, https: absoluteUrl.startsWith('https://'), issues: itemIssues, recommendation });
152 issues.push(...itemIssues);
153 }
154 const uniqIssues = [...new Set(issues)];
155 const count = (fn) => resources.filter(fn).length;
156 return {
157 resources,
158 resourceCount: resources.length,
159 thirdPartyCount: count((r) => r.thirdParty),
160 missingIntegrityCount: count((r) => r.thirdParty && !r.hasIntegrity),
161 weakHashCount: count((r) => r.weakHash),
162 invalidIntegrityCount: count((r) => r.hasIntegrity && !r.validIntegrityFormat),
163 missingCrossoriginCount: count((r) => r.thirdParty && r.hasIntegrity && !r.crossorigin),
164 insecureResourceCount: count((r) => !r.https),
165 issues: uniqIssues,
166 };
167}
168
169export function scoreSri(analysis) {
170 let score = 100;
171 score -= analysis.missingIntegrityCount * 20;
172 score -= analysis.invalidIntegrityCount * 25;
173 score -= analysis.missingCrossoriginCount * 10;
174 score -= analysis.weakHashCount * 5;
175 score -= analysis.insecureResourceCount * 25;
176 return Math.max(0, Math.min(100, score));
177}
178
179export function gradeFromScore(score) {
180 if (score >= 97) return 'A+';
181 if (score >= 90) return 'A';
182 if (score >= 80) return 'B';
183 if (score >= 70) return 'C';
184 if (score >= 60) return 'D';
185 if (score >= 40) return 'E';
186 return 'F';
187}
188
189export function buildRecommendations(analysis) {
190 const recs = [];
191 if (analysis.missingIntegrityCount) recs.push('Add integrity hashes to third-party scripts and stylesheets that are stable enough to pin.');
192 if (analysis.invalidIntegrityCount) recs.push('Use valid sha384 or sha512 Subresource Integrity tokens, for example sha384-base64digest.');
193 if (analysis.missingCrossoriginCount) recs.push('Add crossorigin="anonymous" to cross-origin resources that use integrity.');
194 if (analysis.insecureResourceCount) recs.push('Load scripts and stylesheets over HTTPS only.');
195 if (!recs.length) recs.push('SRI posture looks clean for discovered scripts and stylesheets.');
196 return recs;
197}
198
199async function main() {
200 await Actor.init();
201 const input = await Actor.getInput() || {};
202 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
203 const maxHtmlBytes = Math.min(Math.max(Number(input.maxHtmlBytes || DEFAULT_MAX_HTML_BYTES), 16 * 1024), MAX_HTML_BYTES);
204 const inputUrl = input.startUrl;
205 const checkedAt = new Date().toISOString();
206
207 try {
208 const startUrl = await normalizeAndValidateUrl(inputUrl);
209 const fetched = await fetchHtml(startUrl, timeoutSeconds, maxHtmlBytes);
210 const analysis = analyzeSriResources(fetched.html, fetched.finalUrl);
211 const score = fetched.error ? 0 : scoreSri(analysis);
212 await Actor.pushData({
213 inputUrl,
214 finalUrl: fetched.finalUrl,
215 status: fetched.status,
216 https: fetched.https,
217 truncated: fetched.truncated,
218 fetchError: fetched.error,
219 ...analysis,
220 score,
221 grade: gradeFromScore(score),
222 checkedAt,
223 recommendations: fetched.error ? [`Fetch failed: ${fetched.error}`] : buildRecommendations(analysis),
224 });
225 } catch (error) {
226 await Actor.pushData({ inputUrl, finalUrl: null, status: null, https: false, fetchError: error.message, resourceCount: 0, thirdPartyCount: 0, missingIntegrityCount: 0, weakHashCount: 0, invalidIntegrityCount: 0, missingCrossoriginCount: 0, resources: [], issues: [error.message], score: 0, grade: 'F', checkedAt, recommendations: [error.message] });
227 } finally {
228 await Actor.exit();
229 }
230}
231
232if (process.argv[1] === fileURLToPath(import.meta.url)) {
233 await main();
234}