109 lines
4.1 KiB
JavaScript
109 lines
4.1 KiB
JavaScript
|
|
import { URL } from 'url';
|
||
|
|
|
||
|
|
const SECURITY_HEADERS = [
|
||
|
|
{ key: 'strict-transport-security', label: 'Strict-Transport-Security', important: true },
|
||
|
|
{ key: 'content-security-policy', label: 'Content-Security-Policy', important: true },
|
||
|
|
{ key: 'x-frame-options', label: 'X-Frame-Options', important: true },
|
||
|
|
{ key: 'x-content-type-options', label: 'X-Content-Type-Options', important: true },
|
||
|
|
{ key: 'referrer-policy', label: 'Referrer-Policy', important: true },
|
||
|
|
{ key: 'permissions-policy', label: 'Permissions-Policy', important: false },
|
||
|
|
{ key: 'cross-origin-embedder-policy', label: 'Cross-Origin-Embedder-Policy', important: false },
|
||
|
|
{ key: 'cross-origin-opener-policy', label: 'Cross-Origin-Opener-Policy', important: false },
|
||
|
|
{ key: 'cross-origin-resource-policy', label: 'Cross-Origin-Resource-Policy', important: false },
|
||
|
|
];
|
||
|
|
|
||
|
|
function headerGrade(score) {
|
||
|
|
if (score >= 90) return 'A+';
|
||
|
|
if (score >= 75) return 'A';
|
||
|
|
if (score >= 60) return 'B';
|
||
|
|
if (score >= 45) return 'C';
|
||
|
|
if (score >= 30) return 'D';
|
||
|
|
return 'F';
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function checkSecurityHeaders(urlStr) {
|
||
|
|
try {
|
||
|
|
const res = await fetch(urlStr, {
|
||
|
|
method: 'GET',
|
||
|
|
redirect: 'follow',
|
||
|
|
signal: AbortSignal.timeout(12000),
|
||
|
|
});
|
||
|
|
// Cancel body — we only need headers
|
||
|
|
await res.body?.cancel();
|
||
|
|
|
||
|
|
const headers = SECURITY_HEADERS.map(h => ({
|
||
|
|
key: h.key,
|
||
|
|
label: h.label,
|
||
|
|
important: h.important,
|
||
|
|
present: res.headers.has(h.key),
|
||
|
|
value: res.headers.get(h.key),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const important = headers.filter(h => h.important);
|
||
|
|
const optional = headers.filter(h => !h.important);
|
||
|
|
const impScore = (important.filter(h => h.present).length / important.length) * 70;
|
||
|
|
const optScore = optional.length
|
||
|
|
? (optional.filter(h => h.present).length / optional.length) * 30
|
||
|
|
: 30;
|
||
|
|
|
||
|
|
const score = Math.round(impScore + optScore);
|
||
|
|
return { score, grade: headerGrade(score), headers };
|
||
|
|
} catch (err) {
|
||
|
|
console.warn('[checker] headers check failed:', err.message);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── SSL Labs ─────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const SSL_API = 'https://api.ssllabs.com/api/v3/analyze';
|
||
|
|
|
||
|
|
function parseSSLData(data) {
|
||
|
|
const ep = data.endpoints?.[0];
|
||
|
|
if (!ep) return null;
|
||
|
|
const notAfter = ep.details?.cert?.notAfter;
|
||
|
|
return {
|
||
|
|
grade: ep.grade || 'T',
|
||
|
|
ipAddress: ep.ipAddress || null,
|
||
|
|
certExpiry: notAfter ? new Date(notAfter).toISOString().split('T')[0] : null,
|
||
|
|
daysUntilExpiry: notAfter ? Math.round((notAfter - Date.now()) / 86_400_000) : null,
|
||
|
|
protocols: ep.details?.protocols?.map(p => `${p.name} ${p.version}`) ?? [],
|
||
|
|
statusMessage: ep.statusMessage || null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function checkSSL(urlStr) {
|
||
|
|
try {
|
||
|
|
const host = new URL(urlStr).hostname;
|
||
|
|
if (!urlStr.startsWith('https')) return null;
|
||
|
|
|
||
|
|
// Kick off assessment (or retrieve existing)
|
||
|
|
const init = await fetch(
|
||
|
|
`${SSL_API}?host=${encodeURIComponent(host)}&publish=off&startNew=on&all=done`,
|
||
|
|
{ signal: AbortSignal.timeout(15000) }
|
||
|
|
);
|
||
|
|
if (!init.ok) return null;
|
||
|
|
const initData = await init.json();
|
||
|
|
if (initData.status === 'READY') return parseSSLData(initData);
|
||
|
|
if (initData.status === 'ERROR') return null;
|
||
|
|
|
||
|
|
// Poll until READY (up to 5 min)
|
||
|
|
const deadline = Date.now() + 5 * 60_000;
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
await new Promise(r => setTimeout(r, 10_000));
|
||
|
|
const poll = await fetch(
|
||
|
|
`${SSL_API}?host=${encodeURIComponent(host)}&publish=off&all=done`,
|
||
|
|
{ signal: AbortSignal.timeout(15000) }
|
||
|
|
);
|
||
|
|
if (!poll.ok) continue;
|
||
|
|
const data = await poll.json();
|
||
|
|
if (data.status === 'READY') return parseSSLData(data);
|
||
|
|
if (data.status === 'ERROR') return null;
|
||
|
|
}
|
||
|
|
return null; // timed out
|
||
|
|
} catch (err) {
|
||
|
|
console.warn('[checker] SSL check failed:', err.message);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|