pediatric-ai-scribe-v3/src/utils/logger.js
Daniel 263bd1a1c7 Security hardening: PHI encryption, argon2, DOMPurify, SRI
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
  rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
  fallback; bcrypt hashes rehashed to argon2id on next successful login.
  argon2 package is optional — server keeps running with bcrypt only
  until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
  phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
  Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
  (src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
  nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example

Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
2026-04-14 02:49:38 +02:00

153 lines
6.4 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const db = require('../db/database');
const { redact } = require('./redact');
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));
db.run(
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
userId || null, action, e.category || 'general',
safeDetails,
req ? (req.ip || null) : null,
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
]
).catch(function(err) { console.error('[Logger] Audit failed:', err.message); });
pushToLoki(
{ type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' },
JSON.stringify({
userId: userId, action: action, details: safeDetails,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
sessionId: req ? (req.sessionId || null) : null,
status: e.status || 'success'
})
);
},
apiCall: function(userId, endpoint, data) {
var d = data || {};
var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput);
db.run(
'INSERT INTO api_log (user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
userId || null, endpoint, d.method || 'POST', d.statusCode || 200,
d.requestSize || 0, d.responseSize || 0, d.model || null,
d.tokensInput || 0, d.tokensOutput || 0, cost, d.duration || 0,
d.ip || null, d.error || null
]
).catch(function(err) { console.error('[Logger] API log failed:', err.message); });
pushToLoki(
{ type: 'api_call', endpoint: endpoint, model: d.model || '' },
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
})
);
},
access: function(userId, action, req, success) {
db.run(
'INSERT INTO access_log (user_id, action, ip_address, user_agent, success) VALUES (?, ?, ?, ?, ?)',
[
userId || null, action,
req ? (req.ip || null) : null,
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
success ? true : false
]
).catch(function(err) { console.error('[Logger] Access log failed:', err.message); });
pushToLoki(
{ type: 'access', action: action },
JSON.stringify({
userId: userId, action: action,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
sessionId: req ? (req.sessionId || null) : null,
success: success
})
);
},
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');
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message;
if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data));
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;