Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .github/workflows/tls-cert-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: TLS Certificate Expiry Check

on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
pull_request:
branches: [main]
paths:
- 'scripts/check-ssl-expiry.mjs'
- '.github/workflows/tls-cert-check.yml'

permissions:
contents: read
issues: write

jobs:
tls-expiry:
name: Check TLS certificate expiry
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Check certificate expiry (30-day alert window)
env:
SSL_CHECK_DOMAINS: ${{ vars.SSL_CHECK_DOMAINS }}
SSL_EXPIRY_WARN_DAYS: '30'
SSL_CHECK_SUMMARY: tls-cert-report.md
run: node scripts/check-ssl-expiry.mjs

- name: Publish report to job summary
if: always()
run: |
if [ -f tls-cert-report.md ]; then
cat tls-cert-report.md >> "$GITHUB_STEP_SUMMARY"
fi

- name: Alert maintainers on expiring certificates
if: failure() && github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
TITLE="TLS certificates expiring within 30 days"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "TLS certificates expiring" --json number --jq '.[0].number')
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file tls-cert-report.md
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label security --body-file tls-cert-report.md
fi
14 changes: 14 additions & 0 deletions .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,17 @@ jobs:
frontend/tests/visual/__snapshots__/**/*-actual.png
retention-days: 30
if-no-files-found: ignore

- name: Publish visual diff report to job summary
if: always()
env:
VISUAL_DIFF_ROOT: frontend/tests/visual
VISUAL_DIFF_SUMMARY: visual-diff-summary.md
run: node scripts/visual-diff-summary.mjs

- name: Append report to run summary
if: always()
run: |
if [ -f visual-diff-summary.md ]; then
cat visual-diff-summary.md >> "$GITHUB_STEP_SUMMARY"
fi
4 changes: 4 additions & 0 deletions backend/src/dal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { createSqliteCampaignRewardNotificationRepository } from './sqliteCampai
import { runPgMigrations } from './pg/migrate.js';
import { createPgCampaignRepository } from './pg/pgCampaignRepository.js';
import { createPgAuditLogRepository } from './pg/pgAuditLogRepository.js';
import { instrumentDatabase } from '../db/slowQueryLog.js';

/**
* Build the DAL.
Expand Down Expand Up @@ -57,6 +58,9 @@ export async function createDal({
} = {}) {
const db = new Database(dbPath);
await runMigrations(db);
instrumentDatabase(db, {
thresholdMs: Number(process.env.DB_SLOW_QUERY_MS ?? 200),
});

const categories = allowedCategories ?? parseCategoriesConfig(process.env.TRIVELA_CATEGORIES);

Expand Down
134 changes: 134 additions & 0 deletions backend/src/db/slowQueryLog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// @ts-check
import { log } from '../middleware/logger.js';

/** Queries at or above this duration (ms) are reported. */
export const DEFAULT_SLOW_QUERY_MS = 200;

/**
* Parses a Sentry DSN into the pieces needed for the store endpoint.
*
* @param {string} dsn
*/
function parseSentryDsn(dsn) {
try {
const url = new URL(dsn);
const publicKey = decodeURIComponent(url.username);
const projectId = url.pathname.replace(/^\//, '');
if (!publicKey || !projectId) return null;
return {
storeUrl: `${url.origin}/api/${projectId}/store/`,
publicKey,
};
} catch {
return null;
}
}

/**
* @typedef {{ sql: string, durationMs: number, thresholdMs: number, detail?: string }} SlowQueryEvent
*/

/**
* Creates a slow-query reporter that logs queries at or above the threshold
* and fans the event out to configured sinks (Sentry, CloudWatch, …).
*
* The default log sink writes a structured JSON line, which is what the
* CloudWatch agent ships from the backend's stdout.
*
* @param {{ thresholdMs?: number, sinks?: Array<(event: SlowQueryEvent) => void>, sentryDsn?: string }} [options]
*/
export function createSlowQueryReporter(options = {}) {
const thresholdMs = options.thresholdMs ?? DEFAULT_SLOW_QUERY_MS;
const sinks = [...(options.sinks ?? [])];

const sentryDsn = options.sentryDsn ?? process.env.SENTRY_DSN ?? '';
const sentry = sentryDsn ? parseSentryDsn(sentryDsn) : null;

return {
thresholdMs,
/** @param {SlowQueryEvent} event */
report(event) {
if (event.durationMs < thresholdMs) return;

log.warn({
event: 'db.slow_query',
sql: event.sql,
duration_ms: event.durationMs,
threshold_ms: thresholdMs,
...(event.detail ? { detail: event.detail } : {}),
});

if (sentry) {
fetch(sentry.storeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sentry-Auth': `Sentry sentry_version=7, sentry_key=${sentry.publicKey}`,
},
body: JSON.stringify({
level: 'warning',
logger: 'db.slow_query',
message: `Slow query (${event.durationMs}ms ≥ ${thresholdMs}ms): ${event.sql.slice(0, 200)}`,
tags: { duration_ms: String(event.durationMs), threshold_ms: String(thresholdMs) },
}),
}).catch(() => {
// Telemetry must never break query execution.
});
}

for (const sink of sinks) {
try {
sink(event);
} catch {
// Same guarantee as above.
}
}
},
};
}

/**
* Times `fn` and reports when it runs slowly.
*
* @template T
* @param {{ sql: string, detail?: string }} meta
* @param {() => T} fn
* @param {{ report: (event: SlowQueryEvent) => void }} reporter
* @returns {T}
*/
export function timeSyncQuery(meta, fn, reporter) {
const start = process.hrtime.bigint();
try {
return fn();
} finally {
reporter.report({
sql: meta.sql,
durationMs: Number(process.hrtime.bigint() - start) / 1e6,
...(meta.detail ? { detail: meta.detail } : {}),
});
}
}

/**
* Instruments a better-sqlite3 database so every prepared statement
* execution is timed and slow ones are reported.
*
* @param {import('better-sqlite3').Database} db
* @param {{ thresholdMs?: number, sinks?: Array<(event: SlowQueryEvent) => void>, sentryDsn?: string }} [options]
*/
export function instrumentDatabase(db, options = {}) {
const reporter = createSlowQueryReporter(options);
const originalPrepare = db.prepare.bind(db);

db.prepare = (sql) => {
const stmt = originalPrepare(sql);
for (const method of /** @type {const} */ (['get', 'all', 'run'])) {
const original = stmt[method].bind(stmt);
stmt[method] = (...args) =>
timeSyncQuery({ sql, detail: method }, () => original(...args), reporter);
}
return stmt;
};

return reporter;
}
123 changes: 123 additions & 0 deletions backend/src/db/slowQueryLog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// @ts-check
import test from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import {
DEFAULT_SLOW_QUERY_MS,
createSlowQueryReporter,
instrumentDatabase,
timeSyncQuery,
} from './slowQueryLog.js';

test('reporter ignores queries below the threshold', () => {
/** @type {any[]} */
const events = [];
const reporter = createSlowQueryReporter({ thresholdMs: 200, sinks: [events.push.bind(events)] });

reporter.report({ sql: 'SELECT 1', durationMs: 50 });

assert.equal(events.length, 0, 'sub-threshold queries must not be reported');
});

test('reporter emits slow queries to every sink', () => {
/** @type {any[]} */
const a = [];
/** @type {any[]} */
const b = [];
const reporter = createSlowQueryReporter({ thresholdMs: 200, sinks: [a.push.bind(a), b.push.bind(b)] });

reporter.report({ sql: 'SELECT * FROM campaigns', durationMs: 250, detail: 'get' });

assert.equal(a.length, 1);
assert.equal(b.length, 1);
assert.equal(a[0].sql, 'SELECT * FROM campaigns');
assert.equal(a[0].durationMs, 250);
assert.equal(a[0].detail, 'get');
});

test('reporter threshold is configurable and defaults to 200ms', () => {
assert.equal(DEFAULT_SLOW_QUERY_MS, 200);
assert.equal(createSlowQueryReporter().thresholdMs, 200);
assert.equal(createSlowQueryReporter({ thresholdMs: 50 }).thresholdMs, 50);
});

test('a throwing sink does not break reporting', () => {
/** @type {any[]} */
const events = [];
const reporter = createSlowQueryReporter({
thresholdMs: 1,
sinks: [
() => {
throw new Error('sink exploded');
},
events.push.bind(events),
],
});

reporter.report({ sql: 'SELECT 1', durationMs: 10 });

assert.equal(events.length, 1, 'later sinks must still receive the event');
});

test('timeSyncQuery reports only slow executions', () => {
/** @type {any[]} */
const events = [];
const reporter = createSlowQueryReporter({ thresholdMs: 0.0001, sinks: [events.push.bind(events)] });

const result = timeSyncQuery({ sql: 'SELECT 42', detail: 'get' }, () => 42, reporter);

assert.equal(result, 42);
assert.equal(events.length, 1);
assert.equal(events[0].sql, 'SELECT 42');
assert.ok(events[0].durationMs >= 0, 'durationMs must be non-negative');
});

test('timeSyncQuery reports even when the query throws', () => {
/** @type {any[]} */
const events = [];
const reporter = createSlowQueryReporter({ thresholdMs: 0, sinks: [events.push.bind(events)] });

assert.throws(() =>
timeSyncQuery({ sql: 'SELECT bad' }, () => {
throw new Error('boom');
}, reporter),
);
assert.equal(events.length, 1, 'failed slow queries must still be reported');
});

test('instrumentDatabase times prepared statement methods', () => {
/** @type {any[]} */
const events = [];
const db = new Database(':memory:');
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)');

instrumentDatabase(db, { thresholdMs: 0, sinks: [events.push.bind(events)] });

db.prepare('INSERT INTO t (name) VALUES (?)').run('alpha');
db.prepare('SELECT * FROM t').all();
db.prepare('SELECT * FROM t WHERE id = ?').get(1);

const sqls = events.map((e) => e.sql);
assert.ok(sqls.includes('INSERT INTO t (name) VALUES (?)'));
assert.ok(sqls.includes('SELECT * FROM t'));
assert.ok(sqls.includes('SELECT * FROM t WHERE id = ?'));
assert.equal(events.length, 3, 'each statement execution must be timed');

db.close();
});

test('instrumentDatabase still returns correct query results', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)');

instrumentDatabase(db, { thresholdMs: 10_000 });

db.prepare('INSERT INTO t (name) VALUES (?)').run('alpha');
const rows = db.prepare('SELECT name FROM t').all();
const row = db.prepare('SELECT name FROM t WHERE id = ?').get(1);

assert.deepEqual(rows, [{ name: 'alpha' }]);
assert.deepEqual(row, { name: 'alpha' });

db.close();
});
Loading
Loading