diff --git a/.github/workflows/tls-cert-check.yml b/.github/workflows/tls-cert-check.yml new file mode 100644 index 00000000..01e89a02 --- /dev/null +++ b/.github/workflows/tls-cert-check.yml @@ -0,0 +1,42 @@ +name: TLS Certificate Expiry Check + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + inputs: + endpoints: + description: 'Comma-separated host[:port] list (overrides repository variable)' + required: false + pull_request: + paths: + - 'backend/src/security/**' + - 'backend/scripts/check-tls-certs.js' + - '.github/workflows/tls-cert-check.yml' + +jobs: + tls-cert-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install backend dependencies + run: npm ci --workspace=backend + + - name: Run unit tests + run: node --test src/security/*.test.js + working-directory: backend + + # Fails (and so notifies maintainers) when any cert expires within 30 days. + # Configure endpoints via the TLS_CHECK_ENDPOINTS repository variable. + - name: Check certificate expiry + if: github.event_name != 'pull_request' + run: node scripts/check-tls-certs.js --warn-days=30 + working-directory: backend + env: + TLS_CHECK_ENDPOINTS: ${{ github.event.inputs.endpoints || vars.TLS_CHECK_ENDPOINTS }} diff --git a/backend/package.json b/backend/package.json index b44d1840..80d1e839 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,8 @@ "docs:build": "node scripts/build-redoc.js", "openapi:lint": "redocly lint openapi.yaml", "security:xss": "node scripts/security-audit.js", - "security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'" + "security-headers-check": "curl -s -I ${CHECK_URL:-http://localhost:3001/health} | grep -iE '(x-content-type|x-frame|strict-transport|content-security|referrer|permissions)'", + "security:tls": "node scripts/check-tls-certs.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.1057.0", diff --git a/backend/scripts/check-tls-certs.js b/backend/scripts/check-tls-certs.js new file mode 100644 index 00000000..23ccf7f1 --- /dev/null +++ b/backend/scripts/check-tls-certs.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Usage: node scripts/check-tls-certs.js [--warn-days=30] host[:port] ... +// Endpoints may also come from TLS_CHECK_ENDPOINTS (comma separated). +// Exits 1 if any certificate expires within the warning window, is expired, or cannot be checked. +import { checkEndpoints, DEFAULT_WARN_DAYS } from '../src/security/tlsCertCheck.js'; + +const args = process.argv.slice(2); +let warnDays = Number(process.env.TLS_WARN_DAYS ?? DEFAULT_WARN_DAYS); +const endpoints = (process.env.TLS_CHECK_ENDPOINTS || '').split(',').map((s) => s.trim()).filter(Boolean); +for (const arg of args) { + if (arg.startsWith('--warn-days=')) warnDays = Number(arg.split('=')[1]); + else endpoints.push(arg); +} + +if (!Number.isFinite(warnDays) || warnDays < 0 || endpoints.length === 0) { + console.error('Usage: check-tls-certs.js [--warn-days=30] host[:port] ... (or set TLS_CHECK_ENDPOINTS)'); + process.exit(2); +} + +const results = await checkEndpoints(endpoints, { warnDays }); +let failed = false; +for (const r of results) { + if (r.status === 'ok') { + console.log(`OK ${r.endpoint}: ${r.daysRemaining} days remaining`); + } else if (r.status === 'error') { + failed = true; + console.error(`::error::TLS check failed for ${r.endpoint}: ${r.error}`); + } else { + failed = true; + console.error(`::error::TLS certificate for ${r.endpoint} ${r.status === 'expired' ? 'has expired' : `expires in ${r.daysRemaining} days`} (threshold ${warnDays})`); + } +} +process.exit(failed ? 1 : 0); diff --git a/backend/src/security/tlsCertCheck.js b/backend/src/security/tlsCertCheck.js new file mode 100644 index 00000000..224a944d --- /dev/null +++ b/backend/src/security/tlsCertCheck.js @@ -0,0 +1,78 @@ +import tls from 'node:tls'; + +export const DEFAULT_WARN_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Parse a host spec ("example.com", "example.com:8443" or an https URL) + * into { host, port }. Throws on invalid input. + */ +export function parseEndpoint(spec) { + if (typeof spec !== 'string' || !spec.trim()) { + throw new TypeError('Endpoint must be a non-empty string'); + } + const raw = spec.trim(); + const url = new URL(raw.includes('://') ? raw : `https://${raw}`); + if (!url.hostname) throw new TypeError(`Invalid endpoint: ${spec}`); + return { host: url.hostname, port: url.port ? Number(url.port) : 443 }; +} + +/** Whole days between `now` and `validTo` (negative when expired). */ +export function daysUntil(validTo, now = new Date()) { + const end = new Date(validTo).getTime(); + if (Number.isNaN(end)) throw new TypeError(`Invalid certificate date: ${validTo}`); + return Math.floor((end - now.getTime()) / DAY_MS); +} + +/** Classify remaining days as ok | warning | expired. */ +export function classify(daysRemaining, warnDays = DEFAULT_WARN_DAYS) { + if (daysRemaining < 0) return 'expired'; + if (daysRemaining <= warnDays) return 'warning'; + return 'ok'; +} + +/** Fetch the peer certificate's expiry from a live TLS endpoint. */ +export function fetchCertExpiry({ host, port = 443 }, { timeoutMs = 10000, connect = tls.connect } = {}) { + return new Promise((resolve, reject) => { + const socket = connect({ host, port, servername: host, rejectUnauthorized: false }); + const fail = (err) => { + socket.destroy(); + reject(err); + }; + socket.setTimeout(timeoutMs, () => fail(new Error(`Timed out connecting to ${host}:${port}`))); + socket.once('error', fail); + socket.once('secureConnect', () => { + const cert = socket.getPeerCertificate(); + socket.end(); + if (!cert || !cert.valid_to) { + reject(new Error(`No certificate returned by ${host}:${port}`)); + return; + } + resolve({ validTo: cert.valid_to, subject: cert.subject?.CN, issuer: cert.issuer?.CN }); + }); + }); +} + +/** + * Check a list of endpoints. Never throws for a single endpoint failure; + * failures are reported with status "error". + */ +export async function checkEndpoints(specs, { warnDays = DEFAULT_WARN_DAYS, now = new Date(), fetchExpiry = fetchCertExpiry } = {}) { + return Promise.all( + specs.map(async (spec) => { + try { + const endpoint = parseEndpoint(spec); + const info = await fetchExpiry(endpoint); + const daysRemaining = daysUntil(info.validTo, now); + return { + endpoint: spec, + ...info, + daysRemaining, + status: classify(daysRemaining, warnDays), + }; + } catch (err) { + return { endpoint: spec, status: 'error', error: err.message }; + } + }), + ); +} diff --git a/backend/src/security/tlsCertCheck.test.js b/backend/src/security/tlsCertCheck.test.js new file mode 100644 index 00000000..f5148d02 --- /dev/null +++ b/backend/src/security/tlsCertCheck.test.js @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseEndpoint, daysUntil, classify, checkEndpoints } from './tlsCertCheck.js'; + +const now = new Date('2026-01-01T00:00:00Z'); + +test('parseEndpoint handles host, port and url forms', () => { + assert.deepEqual(parseEndpoint('example.com'), { host: 'example.com', port: 443 }); + assert.deepEqual(parseEndpoint('example.com:8443'), { host: 'example.com', port: 8443 }); + assert.deepEqual(parseEndpoint('https://api.example.com/health'), { host: 'api.example.com', port: 443 }); + assert.throws(() => parseEndpoint(''), TypeError); + assert.throws(() => parseEndpoint(42), TypeError); +}); + +test('daysUntil computes whole days and rejects bad dates', () => { + assert.equal(daysUntil('2026-01-31T00:00:00Z', now), 30); + assert.equal(daysUntil('2025-12-30T00:00:00Z', now), -2); + assert.throws(() => daysUntil('garbage', now), TypeError); +}); + +test('classify boundaries', () => { + assert.equal(classify(-1), 'expired'); + assert.equal(classify(0), 'warning'); + assert.equal(classify(30), 'warning'); + assert.equal(classify(31), 'ok'); + assert.equal(classify(10, 5), 'ok'); +}); + +test('checkEndpoints reports ok, warning, expired and error', async () => { + const dates = { + 'ok.example': '2026-06-01T00:00:00Z', + 'soon.example': '2026-01-15T00:00:00Z', + 'old.example': '2025-12-01T00:00:00Z', + }; + const fetchExpiry = async ({ host }) => { + if (!dates[host]) throw new Error('boom'); + return { validTo: dates[host] }; + }; + const res = await checkEndpoints(['ok.example', 'soon.example', 'old.example', 'bad.example'], { now, fetchExpiry }); + assert.deepEqual(res.map((r) => r.status), ['ok', 'warning', 'expired', 'error']); + assert.equal(res[3].error, 'boom'); +});