add hardening and metrics tests

This commit is contained in:
Daniel 2026-05-08 19:08:29 +02:00
parent 8eb68138b7
commit 61e8045dd2
4 changed files with 244 additions and 0 deletions

77
src/utils/metrics.js Normal file
View file

@ -0,0 +1,77 @@
const client = require('prom-client');
const register = new client.Registry();
client.collectDefaultMetrics({
prefix: 'ped_ai_',
register
});
const httpRequestsTotal = new client.Counter({
name: 'ped_ai_http_requests_total',
help: 'Total HTTP requests received by Ped-AI',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
const httpRequestDuration = new client.Histogram({
name: 'ped_ai_http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register]
});
const httpRequestsInProgress = new client.Gauge({
name: 'ped_ai_http_requests_in_progress',
help: 'Number of Ped-AI HTTP requests currently in progress',
labelNames: ['method', 'route'],
registers: [register]
});
function normalizeRoute(req) {
var routePath = req.route && req.route.path;
var baseUrl = req.baseUrl || '';
if (Array.isArray(routePath)) return baseUrl + routePath[0];
if (routePath) return baseUrl + routePath;
var path = req.path || req.url || 'unknown';
return path
.replace(/\b[0-9a-f]{8,}\b/gi, ':id')
.replace(/\b\d+\b/g, ':id')
.replace(/\?.*$/, '');
}
function metricsMiddleware(req, res, next) {
if (req.path === '/metrics') return next();
var route = normalizeRoute(req);
var method = req.method;
var endTimer = httpRequestDuration.startTimer({ method, route });
httpRequestsInProgress.inc({ method, route });
res.on('finish', function() {
var finalRoute = normalizeRoute(req);
var labels = {
method,
route: finalRoute,
status_code: String(res.statusCode)
};
httpRequestsTotal.inc(labels);
endTimer({ route: finalRoute, status_code: String(res.statusCode) });
httpRequestsInProgress.dec({ method, route });
});
next();
}
async function metricsHandler(req, res) {
res.setHeader('Content-Type', register.contentType);
res.end(await register.metrics());
}
module.exports = {
metricsHandler,
metricsMiddleware,
register
};

42
src/utils/urlSafety.js Normal file
View file

@ -0,0 +1,42 @@
var dns = require('dns').promises;
var net = require('net');
function isPrivateIp(ip) {
if (!ip) return true;
if (net.isIPv4(ip)) {
var p = ip.split('.').map(Number);
if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
if (p[0] === 169 && p[1] === 254) return true;
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
if (p[0] === 192 && p[1] === 168) return true;
if (p[0] >= 224) return true;
return false;
}
if (net.isIPv6(ip)) {
var low = ip.toLowerCase();
if (low === '::1' || low === '::') return true;
if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true;
return false;
}
return true;
}
async function assertSafeHttpsUrl(urlStr, label) {
var u;
try { u = new URL(urlStr); }
catch (e) { throw new Error((label || 'URL') + ' is invalid'); }
if (u.protocol !== 'https:') throw new Error((label || 'URL') + ' must use https://');
if (u.username || u.password) throw new Error((label || 'URL') + ' must not include credentials');
var addrs = await dns.lookup(u.hostname, { all: true });
if (!addrs.length) throw new Error((label || 'URL') + ' did not resolve');
for (var i = 0; i < addrs.length; i++) {
if (isPrivateIp(addrs[i].address)) throw new Error((label || 'URL') + ' resolves to a private IP');
}
return u;
}
module.exports = {
assertSafeHttpsUrl: assertSafeHttpsUrl,
isPrivateIp: isPrivateIp
};

View file

@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
test('redactor removes PHI and common secret patterns from logs', () => {
const { redact } = require('../src/utils/redact');
const input = 'Authorization: Bearer abc.def.ghi password=secret123 token=tok_1234567890 email test@example.com MRN 123456789';
const output = redact(input);
assert.doesNotMatch(output, /abc\.def\.ghi/);
assert.doesNotMatch(output, /secret123/);
assert.doesNotMatch(output, /tok_1234567890/);
assert.doesNotMatch(output, /test@example\.com/);
assert.doesNotMatch(output, /123456789/);
assert.match(output, /\[REDACTED\]|\[JWT\]/);
});
test('URL safety helper blocks private network targets', () => {
const { isPrivateIp } = require('../src/utils/urlSafety');
assert.equal(isPrivateIp('127.0.0.1'), true);
assert.equal(isPrivateIp('10.0.0.5'), true);
assert.equal(isPrivateIp('172.16.0.1'), true);
assert.equal(isPrivateIp('192.168.1.10'), true);
assert.equal(isPrivateIp('169.254.169.254'), true);
assert.equal(isPrivateIp('100.64.0.1'), true);
assert.equal(isPrivateIp('::1'), true);
assert.equal(isPrivateIp('fe80::1'), true);
assert.equal(isPrivateIp('8.8.8.8'), false);
assert.equal(isPrivateIp('2606:4700:4700::1111'), false);
});
test('Nextcloud/WebDAV routes enforce SSRF guard and redirect blocking', () => {
const nextcloud = read('src/routes/nextcloud.js');
const learningAI = read('src/routes/learningAI.js');
assert.match(nextcloud, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
assert.match(nextcloud, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
assert.match(nextcloud, /maxRedirects: 0/);
assert.match(nextcloud, /encodeURIComponent\(username\)/);
assert.match(learningAI, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
assert.match(learningAI, /maxRedirects: 0/);
assert.match(learningAI, /encodeURIComponent\(user\.nextcloud_user\)/);
});
test('logs and audits avoid unbounded limits and PHI-prone details', () => {
const logs = read('src/routes/logs.js');
const encounters = read('src/routes/encounters.js');
const documents = read('src/routes/documents.js');
const learningAI = read('src/routes/learningAI.js');
assert.match(logs, /function clampLimit/);
assert.match(logs, /clampLimit\(req\.query\.limit, 50, 200\)/);
assert.match(logs, /logger\.warn\('client_error'/);
assert.match(logs, /redact\(trimField\(e\.stack, 1200\)\)/);
assert.doesNotMatch(logs, /console\.error\('\[CLIENT ERROR\]'/);
assert.doesNotMatch(encounters, /Saved encounter: ' \+ \(label/);
assert.doesNotMatch(encounters, /Loaded encounter: ' \+ \(row\.label/);
assert.doesNotMatch(documents, /Uploaded: ' \+ \(req\.file/);
assert.doesNotMatch(learningAI, /Char context/);
});

58
test/metrics.test.js Normal file
View file

@ -0,0 +1,58 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('node:events');
const { metricsHandler, metricsMiddleware, register } = require('../src/utils/metrics');
test('metrics middleware records HTTP request counters and durations', async () => {
const req = {
method: 'GET',
path: '/api/encounters/123',
url: '/api/encounters/123',
baseUrl: '',
route: null
};
const res = new EventEmitter();
res.statusCode = 200;
await new Promise((resolve) => metricsMiddleware(req, res, resolve));
res.emit('finish');
const metrics = await register.metrics();
assert.match(metrics, /ped_ai_http_requests_total/);
assert.match(metrics, /route="\/api\/encounters\/:id"/);
assert.match(metrics, /ped_ai_http_request_duration_seconds_count/);
});
test('metrics middleware uses the first path for Express array routes', async () => {
const req = {
method: 'GET',
path: '/',
url: '/',
baseUrl: '',
route: { path: ['/', '/index.html'] }
};
const res = new EventEmitter();
res.statusCode = 200;
await new Promise((resolve) => metricsMiddleware(req, res, resolve));
res.emit('finish');
const metrics = await register.metrics();
assert.match(metrics, /route="\/"/);
assert.doesNotMatch(metrics, /route="\/,\/index\.html"/);
});
test('metrics handler exposes prometheus content type', async () => {
const headers = {};
const res = {
body: '',
setHeader: function(name, value) { headers[name] = value; },
end: function(body) { this.body = body; }
};
await metricsHandler({}, res);
assert.equal(headers['Content-Type'], register.contentType);
assert.match(res.body, /ped_ai_process_cpu_user_seconds_total/);
});