From bb2b3384f0eea31e254f0d5024214d3d8d8b4d88 Mon Sep 17 00:00:00 2001 From: Malin Date: Tue, 7 Apr 2026 12:10:22 +0200 Subject: [PATCH] feat: add security headers + SSL Labs checks; compact layout - checker.js: checkSecurityHeaders() graded A+-F, checkSSL() via SSL Labs API - Both run in parallel with sitespeed.io to minimise wait time - DB: auto-migrate headers_json + ssl_json columns - Layout: coach scores + CWV side-by-side, headers + SSL below - Scorecard + CWV made compact Co-Authored-By: Claude Sonnet 4.6 --- checker.js | 108 +++++++++++++++++++++++++++++++++++ db.js | 8 +++ queue.js | 13 +++++ views/partials/cwv.pug | 12 ++-- views/partials/headers.pug | 21 +++++++ views/partials/scorecard.pug | 11 ++-- views/partials/ssl.pug | 27 +++++++++ views/results.pug | 14 +++-- 8 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 checker.js create mode 100644 views/partials/headers.pug create mode 100644 views/partials/ssl.pug diff --git a/checker.js b/checker.js new file mode 100644 index 0000000..3f6d4cb --- /dev/null +++ b/checker.js @@ -0,0 +1,108 @@ +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; + } +} diff --git a/db.js b/db.js index 2925341..ad85b17 100644 --- a/db.js +++ b/db.js @@ -17,6 +17,13 @@ export function getDb() { return db; } +function migrateSchema() { + // Add columns introduced after initial schema — safe to run on existing DBs + const add = (col, type) => { try { db.exec(`ALTER TABLE jobs ADD COLUMN ${col} ${type}`); } catch {} }; + add('headers_json', 'TEXT'); + add('ssl_json', 'TEXT'); +} + function initSchema() { db.exec(` CREATE TABLE IF NOT EXISTS jobs ( @@ -106,6 +113,7 @@ function initSchema() { CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at DESC); CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); `); + migrateSchema(); } export function createJob(id, url, browser, mobile, runs) { diff --git a/queue.js b/queue.js index dcdfd11..e5355ec 100644 --- a/queue.js +++ b/queue.js @@ -1,6 +1,7 @@ import { runTest } from './runner.js'; import { parseResults } from './parser.js'; import { updateJobStatus, updateJobMetrics } from './db.js'; +import { checkSecurityHeaders, checkSSL } from './checker.js'; // SSE subscribers: jobId -> Set of send functions const subscribers = new Map(); @@ -45,6 +46,10 @@ async function processQueue() { updateJobStatus(job.id, 'running'); emit(job.id, 'status', { message: 'Test starting...', phase: 'running' }); + // Start SSL + headers checks in parallel with sitespeed.io + const sslPromise = checkSSL(job.url).catch(() => null); + const headersPromise = checkSecurityHeaders(job.url).catch(() => null); + const outputFolder = await runTest(job, (line) => { emit(job.id, 'log', { line }); }); @@ -60,6 +65,14 @@ async function processQueue() { emit(job.id, 'log', { line: `[parser warning] ${err.message}` }); } + // Collect security check results (may still be running) + emit(job.id, 'status', { message: 'Finalising security checks...', phase: 'security' }); + const [sslResult, headersResult] = await Promise.all([sslPromise, headersPromise]); + updateJobMetrics(job.id, { + ssl_json: sslResult ? JSON.stringify(sslResult) : null, + headers_json: headersResult ? JSON.stringify(headersResult) : null, + }); + updateJobStatus(job.id, 'done', { report_folder: outputFolder }); emit(job.id, 'status', { message: 'Done!', phase: 'done' }); emit(job.id, 'done', { jobId: job.id }); diff --git a/views/partials/cwv.pug b/views/partials/cwv.pug index 3043ae9..747b666 100644 --- a/views/partials/cwv.pug +++ b/views/partials/cwv.pug @@ -8,10 +8,10 @@ - return val <= t[0] ? 'bg-green-50 border-green-300' : val <= t[1] ? 'bg-yellow-50 border-yellow-300' : 'bg-red-50 border-red-300'; - } -div(class='bg-white border border-gray-200 rounded-xl p-5') - h2(class='text-base font-semibold mb-4') Core Web Vitals - div(class='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3') +div(class='bg-white border border-gray-200 rounded-xl p-4 h-full') + h2(class='text-sm font-semibold mb-3 text-gray-600 uppercase tracking-wide') Core Web Vitals + div(class='grid grid-cols-3 sm:grid-cols-5 gap-2') each item in [['LCP', 'lcp', ms(job.lcp)], ['FCP', 'fcp', ms(job.fcp)], ['TBT', 'tbt', raw(job.tbt,'ms')], ['CLS', 'cls', raw(job.cls,'')], ['TTFB', 'ttfb', ms(job.ttfb)]] - div(class=`metric-card border ${cwvClass(item[1], job[item[1]])} text-center`) - div(class='text-2xl font-bold')= item[2] - div(class='text-xs text-gray-500 mt-1')= item[0] + div(class=`border rounded-lg px-2 py-2 ${cwvClass(item[1], job[item[1]])} text-center`) + div(class='text-lg font-bold leading-tight')= item[2] + div(class='text-xs text-gray-500 mt-0.5')= item[0] diff --git a/views/partials/headers.pug b/views/partials/headers.pug new file mode 100644 index 0000000..434046f --- /dev/null +++ b/views/partials/headers.pug @@ -0,0 +1,21 @@ +- const hd = job.headers_json ? JSON.parse(job.headers_json) : null +- function hdColor(g) { return !g ? 'bg-gray-100 text-gray-400' : g === 'A+' ? 'bg-green-100 text-green-700' : g === 'A' ? 'bg-green-100 text-green-700' : g === 'B' ? 'bg-yellow-100 text-yellow-700' : g === 'C' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'; } + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-3') Security Headers + if hd + div(class='flex items-start gap-5 flex-wrap') + div(class='flex flex-col items-center gap-1') + div(class=`score-circle w-14 h-14 text-lg ${hdColor(hd.grade)}`)= hd.grade + span(class='text-xs text-gray-500') Grade + span(class='text-xs text-gray-400 mt-0.5')= hd.headers.filter(h=>h.present).length + '/' + hd.headers.length + div(class='grid grid-cols-1 sm:grid-cols-2 gap-1 flex-1') + each h in hd.headers + div(class=`flex items-center justify-between rounded px-3 py-1.5 text-xs ${h.present ? 'bg-green-50' : h.important ? 'bg-red-50' : 'bg-gray-50'}`) + span(class='font-mono')= h.label + if h.present + span(class='text-green-600 font-bold ml-2') ✓ + else + span(class=`font-bold ml-2 ${h.important ? 'text-red-500' : 'text-gray-400'}`) ✗ + else + p(class='text-sm text-gray-400 italic') Not available diff --git a/views/partials/scorecard.pug b/views/partials/scorecard.pug index 850b9be..be550a9 100644 --- a/views/partials/scorecard.pug +++ b/views/partials/scorecard.pug @@ -1,12 +1,11 @@ -- function scoreColor(s) { return s == null ? '#9ca3af' : s >= 90 ? '#22c55e' : s >= 50 ? '#f59e0b' : '#ef4444'; } - function scoreBg(s) { return s == null ? 'bg-gray-100 text-gray-400' : s >= 90 ? 'bg-green-100 text-green-700' : s >= 50 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'; } -- const scores = [['Overall', job.score_overall], ['Performance', job.score_performance], ['Best Practice', job.score_bestpractice], ['Privacy', job.score_privacy]] +- const scores = [['Overall', job.score_overall], ['Perf', job.score_performance], ['Best Practice', job.score_bestpractice], ['Privacy', job.score_privacy]] -div(class='bg-white border border-gray-200 rounded-xl p-5') - h2(class='text-base font-semibold mb-4') Coach Scores - div(class='flex flex-wrap gap-4 justify-start') +div(class='bg-white border border-gray-200 rounded-xl p-4 h-full') + h2(class='text-sm font-semibold mb-3 text-gray-600 uppercase tracking-wide') Coach Scores + div(class='flex flex-wrap gap-3 justify-start') each item in scores div(class='flex flex-col items-center gap-1') - div(class=`score-circle w-16 h-16 text-xl ${scoreBg(item[1])}`) + div(class=`score-circle w-12 h-12 text-base ${scoreBg(item[1])}`) = item[1] != null ? Math.round(item[1]) : '?' span(class='text-xs text-gray-500 text-center')= item[0] diff --git a/views/partials/ssl.pug b/views/partials/ssl.pug new file mode 100644 index 0000000..75642c5 --- /dev/null +++ b/views/partials/ssl.pug @@ -0,0 +1,27 @@ +- const ssl = job.ssl_json ? JSON.parse(job.ssl_json) : null +- function sslColor(g) { return !g ? 'bg-gray-100 text-gray-400' : ['A+','A','A-'].includes(g) ? 'bg-green-100 text-green-700' : g === 'B' ? 'bg-yellow-100 text-yellow-700' : ['C','D'].includes(g) ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'; } + +div(class='bg-white border border-gray-200 rounded-xl p-5') + h2(class='text-base font-semibold mb-3') SSL / TLS + if ssl + div(class='flex items-start gap-5 flex-wrap') + div(class='flex flex-col items-center gap-1') + div(class=`score-circle w-14 h-14 text-lg ${sslColor(ssl.grade)}`)= ssl.grade || 'T' + span(class='text-xs text-gray-500') Grade + div(class='grid grid-cols-2 sm:grid-cols-3 gap-2 flex-1 text-sm') + div(class='bg-gray-50 rounded-lg px-3 py-2') + div(class='text-xs text-gray-400') IP Address + div(class='font-medium')= ssl.ipAddress || '—' + div(class='bg-gray-50 rounded-lg px-3 py-2') + div(class='text-xs text-gray-400') Cert Expires + div(class='font-medium')= ssl.certExpiry || '—' + div(class='bg-gray-50 rounded-lg px-3 py-2') + div(class='text-xs text-gray-400') Days Until Expiry + - const d = ssl.daysUntilExpiry + div(class=`font-medium ${d != null && d < 30 ? 'text-red-600' : d != null && d < 60 ? 'text-yellow-600' : 'text-green-700'}`) + = d != null ? d + ' days' : '—' + div(class='bg-gray-50 rounded-lg px-3 py-2 col-span-2 sm:col-span-3') + div(class='text-xs text-gray-400 mb-1') Protocols + div(class='font-medium')= ssl.protocols?.join(' · ') || '—' + else + p(class='text-sm text-gray-400 italic') Not available — HTTPS only or check timed out diff --git a/views/results.pug b/views/results.pug index f07a921..89dea0c 100644 --- a/views/results.pug +++ b/views/results.pug @@ -1,7 +1,7 @@ extends layout block content - div(class='space-y-6') + div(class='space-y-5') //- Header div(class='flex flex-wrap items-start justify-between gap-4') div @@ -15,11 +15,15 @@ block content if reportUrl a(href=reportUrl target='_blank' class='text-sm bg-green-600 text-white px-4 py-1.5 rounded hover:bg-green-700') Full Report ↗ - //- Coach Scores - include partials/scorecard + //- Coach Scores + Core Web Vitals side by side + div(class='grid grid-cols-1 md:grid-cols-2 gap-4') + include partials/scorecard + include partials/cwv - //- Core Web Vitals - include partials/cwv + //- Security Headers + SSL side by side + div(class='grid grid-cols-1 md:grid-cols-2 gap-4') + include partials/headers + include partials/ssl //- Navigation Timings include partials/timings