add hardening and metrics tests
This commit is contained in:
parent
a08524d95f
commit
a6807ef7a4
4 changed files with 244 additions and 0 deletions
77
src/utils/metrics.js
Normal file
77
src/utils/metrics.js
Normal 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
42
src/utils/urlSafety.js
Normal 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
|
||||
};
|
||||
67
test/backend-hardening.test.js
Normal file
67
test/backend-hardening.test.js
Normal 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
58
test/metrics.test.js
Normal 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/);
|
||||
});
|
||||
Loading…
Reference in a new issue