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 <noreply@anthropic.com>
This commit is contained in:
108
checker.js
Normal file
108
checker.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
db.js
8
db.js
@@ -17,6 +17,13 @@ export function getDb() {
|
|||||||
return db;
|
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() {
|
function initSchema() {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
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_created ON jobs(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
|
||||||
`);
|
`);
|
||||||
|
migrateSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createJob(id, url, browser, mobile, runs) {
|
export function createJob(id, url, browser, mobile, runs) {
|
||||||
|
|||||||
13
queue.js
13
queue.js
@@ -1,6 +1,7 @@
|
|||||||
import { runTest } from './runner.js';
|
import { runTest } from './runner.js';
|
||||||
import { parseResults } from './parser.js';
|
import { parseResults } from './parser.js';
|
||||||
import { updateJobStatus, updateJobMetrics } from './db.js';
|
import { updateJobStatus, updateJobMetrics } from './db.js';
|
||||||
|
import { checkSecurityHeaders, checkSSL } from './checker.js';
|
||||||
|
|
||||||
// SSE subscribers: jobId -> Set of send functions
|
// SSE subscribers: jobId -> Set of send functions
|
||||||
const subscribers = new Map();
|
const subscribers = new Map();
|
||||||
@@ -45,6 +46,10 @@ async function processQueue() {
|
|||||||
updateJobStatus(job.id, 'running');
|
updateJobStatus(job.id, 'running');
|
||||||
emit(job.id, 'status', { message: 'Test starting...', phase: '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) => {
|
const outputFolder = await runTest(job, (line) => {
|
||||||
emit(job.id, 'log', { line });
|
emit(job.id, 'log', { line });
|
||||||
});
|
});
|
||||||
@@ -60,6 +65,14 @@ async function processQueue() {
|
|||||||
emit(job.id, 'log', { line: `[parser warning] ${err.message}` });
|
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 });
|
updateJobStatus(job.id, 'done', { report_folder: outputFolder });
|
||||||
emit(job.id, 'status', { message: 'Done!', phase: 'done' });
|
emit(job.id, 'status', { message: 'Done!', phase: 'done' });
|
||||||
emit(job.id, 'done', { jobId: job.id });
|
emit(job.id, 'done', { jobId: job.id });
|
||||||
|
|||||||
@@ -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';
|
- 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')
|
div(class='bg-white border border-gray-200 rounded-xl p-4 h-full')
|
||||||
h2(class='text-base font-semibold mb-4') Core Web Vitals
|
h2(class='text-sm font-semibold mb-3 text-gray-600 uppercase tracking-wide') Core Web Vitals
|
||||||
div(class='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3')
|
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)]]
|
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=`border rounded-lg px-2 py-2 ${cwvClass(item[1], job[item[1]])} text-center`)
|
||||||
div(class='text-2xl font-bold')= item[2]
|
div(class='text-lg font-bold leading-tight')= item[2]
|
||||||
div(class='text-xs text-gray-500 mt-1')= item[0]
|
div(class='text-xs text-gray-500 mt-0.5')= item[0]
|
||||||
|
|||||||
21
views/partials/headers.pug
Normal file
21
views/partials/headers.pug
Normal file
@@ -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
|
||||||
@@ -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'; }
|
- 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')
|
div(class='bg-white border border-gray-200 rounded-xl p-4 h-full')
|
||||||
h2(class='text-base font-semibold mb-4') Coach Scores
|
h2(class='text-sm font-semibold mb-3 text-gray-600 uppercase tracking-wide') Coach Scores
|
||||||
div(class='flex flex-wrap gap-4 justify-start')
|
div(class='flex flex-wrap gap-3 justify-start')
|
||||||
each item in scores
|
each item in scores
|
||||||
div(class='flex flex-col items-center gap-1')
|
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]) : '?'
|
= item[1] != null ? Math.round(item[1]) : '?'
|
||||||
span(class='text-xs text-gray-500 text-center')= item[0]
|
span(class='text-xs text-gray-500 text-center')= item[0]
|
||||||
|
|||||||
27
views/partials/ssl.pug
Normal file
27
views/partials/ssl.pug
Normal file
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
extends layout
|
extends layout
|
||||||
|
|
||||||
block content
|
block content
|
||||||
div(class='space-y-6')
|
div(class='space-y-5')
|
||||||
//- Header
|
//- Header
|
||||||
div(class='flex flex-wrap items-start justify-between gap-4')
|
div(class='flex flex-wrap items-start justify-between gap-4')
|
||||||
div
|
div
|
||||||
@@ -15,11 +15,15 @@ block content
|
|||||||
if reportUrl
|
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 ↗
|
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
|
//- Coach Scores + Core Web Vitals side by side
|
||||||
include partials/scorecard
|
div(class='grid grid-cols-1 md:grid-cols-2 gap-4')
|
||||||
|
include partials/scorecard
|
||||||
|
include partials/cwv
|
||||||
|
|
||||||
//- Core Web Vitals
|
//- Security Headers + SSL side by side
|
||||||
include partials/cwv
|
div(class='grid grid-cols-1 md:grid-cols-2 gap-4')
|
||||||
|
include partials/headers
|
||||||
|
include partials/ssl
|
||||||
|
|
||||||
//- Navigation Timings
|
//- Navigation Timings
|
||||||
include partials/timings
|
include partials/timings
|
||||||
|
|||||||
Reference in New Issue
Block a user