Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/tls-cert-check.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions backend/scripts/check-tls-certs.js
Original file line number Diff line number Diff line change
@@ -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);
78 changes: 78 additions & 0 deletions backend/src/security/tlsCertCheck.js
Original file line number Diff line number Diff line change
@@ -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 };
}
}),
);
}
42 changes: 42 additions & 0 deletions backend/src/security/tlsCertCheck.test.js
Original file line number Diff line number Diff line change
@@ -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');
});