Two independent PHI-leak hardenings folded together:
1. forgot-password timing oracle
The hit path previously did SELECT + token gen + UPDATE + SMTP send
before responding; the miss path returned after the SELECT. An
attacker could distinguish registered emails by response latency
(SMTP RTT is hundreds of ms). Response is now sent immediately after
Turnstile, with the DB and email work fired-and-forgotten in a
background async block. Hit and miss take identical wall-clock time.
Also hardened req.body.email to tolerate missing/non-string input
instead of throwing 500.
2. logger.file redaction
logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
without going through redact(). Current callers are metadata-only and
safe, but any future caller writing logger.error('boom', req.body)
would silently drop PHI to disk. Route both message and optional data
through redact() — same helper the audit path already uses. Benign
startup messages pass through unchanged; SSN/phone/email/DOB patterns
are tokenised, long note-body-shaped text is truncated.
172 lines
6.9 KiB
JavaScript
172 lines
6.9 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const db = require('../db/database');
|
|
const { redact } = require('./redact');
|
|
const queues = require('./auditQueue');
|
|
var parseUA;
|
|
try { parseUA = require('./sessions').parseUserAgent; } catch(e) { parseUA = function(ua) { return ua || 'unknown'; }; }
|
|
|
|
var LOG_DIR = path.join(__dirname, '../../data/logs');
|
|
if (!fs.existsSync(LOG_DIR)) {
|
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
}
|
|
|
|
// Loki log shipping (optional — set LOKI_URL env var)
|
|
var LOKI_URL = process.env.LOKI_URL;
|
|
function pushToLoki(labels, message) {
|
|
if (!LOKI_URL) return;
|
|
var payload = {
|
|
streams: [{
|
|
stream: Object.assign({ app: 'pedscribe' }, labels),
|
|
values: [[String(Date.now() * 1000000), typeof message === 'string' ? message : JSON.stringify(message)]]
|
|
}]
|
|
};
|
|
fetch(LOKI_URL + '/loki/api/v1/push', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).catch(function() {}); // fire-and-forget
|
|
}
|
|
|
|
|
|
function estimateCost(model, inputTokens, outputTokens) {
|
|
if (!model || !inputTokens) return 0;
|
|
var rates = {
|
|
'google/gemini-2.5-flash': { input: 0.075, output: 0.30 },
|
|
'google/gemini-2.5-pro': { input: 1.25, output: 5.00 },
|
|
'deepseek/deepseek-chat-v3-0324': { input: 0.14, output: 0.28 },
|
|
'deepseek/deepseek-r1': { input: 0.55, output: 2.19 },
|
|
'deepseek/deepseek-r1:free': { input: 0, output: 0 },
|
|
'qwen/qwen3-30b-a3b:free': { input: 0, output: 0 },
|
|
'anthropic/vendor-model-sonnet-4': { input: 3.00, output: 15.00 },
|
|
'anthropic/vendor-model-3-haiku': { input: 0.25, output: 1.25 },
|
|
'openai/gpt-4.1-mini': { input: 0.40, output: 1.60 },
|
|
'meta-llama/llama-3.3-70b-instruct': { input: 0.10, output: 0.16 }
|
|
};
|
|
var rate = rates[model] || { input: 0.50, output: 1.00 };
|
|
return ((inputTokens / 1000000) * rate.input) + (((outputTokens || 0) / 1000000) * rate.output);
|
|
}
|
|
|
|
var logger = {
|
|
|
|
audit: function(userId, action, details, req, extra) {
|
|
var e = extra || {};
|
|
var safeDetails = redact(typeof details === 'string' ? details : JSON.stringify(details));
|
|
queues.audit.push({
|
|
user_id: userId || null,
|
|
action: action,
|
|
category: e.category || 'general',
|
|
details: safeDetails,
|
|
ip_address: req ? (req.ip || null) : null,
|
|
user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
|
model_used: e.model || null,
|
|
tokens_used: e.tokens || null,
|
|
duration_ms: e.duration || null,
|
|
status: e.status || 'success'
|
|
});
|
|
var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' };
|
|
var auditMsg = JSON.stringify({
|
|
userId: userId, action: action, details: safeDetails,
|
|
ip: req ? req.ip : null,
|
|
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
|
|
device: req ? parseUA(req.headers['user-agent']) : null,
|
|
sessionId: req ? (req.sessionId || null) : null,
|
|
model: e.model || null,
|
|
status: e.status || 'success'
|
|
});
|
|
pushToLoki(auditLabels, auditMsg);
|
|
},
|
|
|
|
apiCall: function(userId, endpoint, data) {
|
|
var d = data || {};
|
|
var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput);
|
|
queues.api.push({
|
|
user_id: userId || null,
|
|
endpoint: endpoint,
|
|
method: d.method || 'POST',
|
|
status_code: d.statusCode || 200,
|
|
request_size: d.requestSize || 0,
|
|
response_size: d.responseSize || 0,
|
|
model_used: d.model || null,
|
|
tokens_input: d.tokensInput || 0,
|
|
tokens_output: d.tokensOutput || 0,
|
|
cost_estimate: cost,
|
|
duration_ms: d.duration || 0,
|
|
ip_address: d.ip || null,
|
|
error: d.error || null
|
|
});
|
|
var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' };
|
|
var apiMsg = JSON.stringify({
|
|
userId: userId, endpoint: endpoint, model: d.model,
|
|
tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration,
|
|
ip: d.ip || null, status: d.statusCode || 200
|
|
});
|
|
pushToLoki(apiLabels, apiMsg);
|
|
},
|
|
|
|
access: function(userId, action, req, success) {
|
|
queues.access.push({
|
|
user_id: userId || null,
|
|
action: action,
|
|
ip_address: req ? (req.ip || null) : null,
|
|
user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
|
success: success ? true : false
|
|
});
|
|
var accessLabels = { type: 'access', action: action };
|
|
var accessMsg = JSON.stringify({
|
|
userId: userId, action: action,
|
|
ip: req ? req.ip : null,
|
|
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
|
|
device: req ? parseUA(req.headers['user-agent']) : null,
|
|
sessionId: req ? (req.sessionId || null) : null,
|
|
success: success
|
|
});
|
|
pushToLoki(accessLabels, accessMsg);
|
|
},
|
|
|
|
file: function(level, message, data) {
|
|
try {
|
|
var now = new Date();
|
|
var date = now.toISOString().split('T')[0];
|
|
var logFile = path.join(LOG_DIR, date + '.log');
|
|
// Defensive redaction: route both message and optional data through
|
|
// redact() so PHI patterns (SSN, phone, email, DoB) and note-body
|
|
// heuristics can't leak to the daily log file if a caller accidentally
|
|
// passes a request body, clinical string, or stack trace containing
|
|
// transcript text.
|
|
var safeMessage = redact(String(message == null ? '' : message));
|
|
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + safeMessage;
|
|
if (data != null) {
|
|
var raw = typeof data === 'string' ? data : JSON.stringify(data);
|
|
line += ' | ' + redact(raw);
|
|
}
|
|
line += '\n';
|
|
fs.appendFileSync(logFile, line);
|
|
if (level === 'error') console.error(line.trim());
|
|
} catch (e) {}
|
|
},
|
|
|
|
info: function(msg, data) { this.file('info', msg, data); },
|
|
warn: function(msg, data) { this.file('warn', msg, data); },
|
|
error: function(msg, data) { this.file('error', msg, data); },
|
|
|
|
getAuditLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getApiLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM api_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getAccessLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM access_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getUsageSummary: function(userId, days) {
|
|
var since = new Date(Date.now() - (days || 30) * 86400000).toISOString();
|
|
return db.all(
|
|
'SELECT COUNT(*) as total_calls, SUM(tokens_input) as total_input_tokens, SUM(tokens_output) as total_output_tokens, SUM(cost_estimate) as total_estimated_cost, AVG(duration_ms) as avg_duration_ms, model_used, endpoint FROM api_log WHERE user_id = ? AND timestamp > ? GROUP BY model_used, endpoint ORDER BY total_calls DESC',
|
|
[userId, since]
|
|
);
|
|
}
|
|
};
|
|
|
|
console.log('✅ Logger initialized');
|
|
module.exports = logger;
|