282 lines
7.4 KiB
JavaScript
282 lines
7.4 KiB
JavaScript
// src/middleware/dbAccessLogger.js
|
|
// === SECURITY HARDENING: DATABASE ACCESS LOGGING ===
|
|
// Logs all database access for security auditing and compliance
|
|
|
|
/**
|
|
* Database Access Logging
|
|
*
|
|
* Logs all database queries for security auditing:
|
|
* - Query type (SELECT, INSERT, UPDATE, DELETE)
|
|
* - Table names accessed
|
|
* - Timestamp
|
|
* - User context (if available from request)
|
|
* - IP address
|
|
* - Query parameters (sanitized - no sensitive data)
|
|
*
|
|
* Configuration:
|
|
* - DB_ACCESS_LOGGING_ENABLED: Set to 'true' to enable logging (default: false)
|
|
* - DB_ACCESS_LOG_LEVEL: 'all' (all queries) or 'sensitive' (only sensitive tables) (default: 'sensitive')
|
|
*
|
|
* Sensitive tables (always logged if enabled):
|
|
* - users
|
|
* - otp_codes
|
|
* - otp_requests
|
|
* - refresh_tokens
|
|
* - auth_audit
|
|
*/
|
|
|
|
const db = require('../db');
|
|
|
|
const LOGGING_ENABLED = process.env.DB_ACCESS_LOGGING_ENABLED === 'true' || process.env.DB_ACCESS_LOGGING_ENABLED === '1';
|
|
const LOG_LEVEL = process.env.DB_ACCESS_LOG_LEVEL || 'sensitive'; // 'all' or 'sensitive'
|
|
|
|
// Tables that contain sensitive data (always logged)
|
|
const SENSITIVE_TABLES = [
|
|
'users',
|
|
'otp_codes',
|
|
'otp_requests',
|
|
'refresh_tokens',
|
|
'auth_audit',
|
|
'user_devices',
|
|
];
|
|
|
|
/**
|
|
* Extract table names from SQL query
|
|
* @param {string} query - SQL query text
|
|
* @returns {string[]} - Array of table names
|
|
*/
|
|
function extractTableNames(query) {
|
|
const tables = [];
|
|
const upperQuery = query.toUpperCase();
|
|
|
|
// Match FROM, JOIN, UPDATE, INSERT INTO patterns
|
|
const patterns = [
|
|
/FROM\s+([a-z_]+)/gi,
|
|
/JOIN\s+([a-z_]+)/gi,
|
|
/UPDATE\s+([a-z_]+)/gi,
|
|
/INSERT\s+INTO\s+([a-z_]+)/gi,
|
|
/DELETE\s+FROM\s+([a-z_]+)/gi,
|
|
];
|
|
|
|
patterns.forEach(pattern => {
|
|
const matches = query.matchAll(pattern);
|
|
for (const match of matches) {
|
|
if (match[1]) {
|
|
tables.push(match[1].toLowerCase());
|
|
}
|
|
}
|
|
});
|
|
|
|
return [...new Set(tables)]; // Remove duplicates
|
|
}
|
|
|
|
/**
|
|
* Determine query type from SQL
|
|
* @param {string} query - SQL query text
|
|
* @returns {string} - Query type (SELECT, INSERT, UPDATE, DELETE, etc.)
|
|
*/
|
|
function getQueryType(query) {
|
|
const upperQuery = query.trim().toUpperCase();
|
|
if (upperQuery.startsWith('SELECT')) return 'SELECT';
|
|
if (upperQuery.startsWith('INSERT')) return 'INSERT';
|
|
if (upperQuery.startsWith('UPDATE')) return 'UPDATE';
|
|
if (upperQuery.startsWith('DELETE')) return 'DELETE';
|
|
if (upperQuery.startsWith('CREATE')) return 'CREATE';
|
|
if (upperQuery.startsWith('ALTER')) return 'ALTER';
|
|
if (upperQuery.startsWith('DROP')) return 'DROP';
|
|
return 'OTHER';
|
|
}
|
|
|
|
/**
|
|
* Sanitize query parameters (remove sensitive data)
|
|
* @param {Array} params - Query parameters
|
|
* @returns {Array} - Sanitized parameters (sensitive values replaced with '[REDACTED]')
|
|
*/
|
|
function sanitizeParams(params) {
|
|
if (!params || !Array.isArray(params)) {
|
|
return [];
|
|
}
|
|
|
|
return params.map(param => {
|
|
if (typeof param === 'string') {
|
|
// Check if it looks like a phone number
|
|
if (/^\+?\d{10,15}$/.test(param)) {
|
|
return '[REDACTED_PHONE]';
|
|
}
|
|
// Check if it looks like a token or hash
|
|
if (param.length > 50) {
|
|
return '[REDACTED_TOKEN]';
|
|
}
|
|
// Check if it looks like an encrypted value (format: iv:authTag:data)
|
|
if (param.includes(':') && param.split(':').length === 3) {
|
|
return '[REDACTED_ENCRYPTED]';
|
|
}
|
|
}
|
|
// For other types, return as-is (numbers, booleans, etc.)
|
|
return param;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if query should be logged
|
|
* @param {string} query - SQL query text
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldLogQuery(query) {
|
|
if (!LOGGING_ENABLED) {
|
|
return false;
|
|
}
|
|
|
|
if (LOG_LEVEL === 'all') {
|
|
return true;
|
|
}
|
|
|
|
// Log sensitive tables
|
|
const tables = extractTableNames(query);
|
|
return tables.some(table => SENSITIVE_TABLES.includes(table));
|
|
}
|
|
|
|
/**
|
|
* Log database access
|
|
* @param {Object} logData - Log data
|
|
*/
|
|
async function logDbAccess(logData) {
|
|
if (!LOGGING_ENABLED) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Ensure db_access_log table exists
|
|
await ensureDbAccessLogTable();
|
|
|
|
const {
|
|
query,
|
|
queryType,
|
|
tables,
|
|
params,
|
|
userId = null,
|
|
ipAddress = null,
|
|
userAgent = null,
|
|
duration = null,
|
|
} = logData;
|
|
|
|
await db.query(
|
|
`INSERT INTO db_access_log (
|
|
query_type, tables_accessed, query_text, params_sanitized,
|
|
user_id, ip_address, user_agent, duration_ms, created_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`,
|
|
[
|
|
queryType,
|
|
JSON.stringify(tables),
|
|
query.substring(0, 1000), // Truncate long queries
|
|
JSON.stringify(sanitizeParams(params)),
|
|
userId,
|
|
ipAddress,
|
|
userAgent,
|
|
duration,
|
|
]
|
|
);
|
|
} catch (err) {
|
|
// Don't throw - logging failures should not break the application
|
|
console.error('[dbAccessLogger] Failed to log database access:', err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure db_access_log table exists
|
|
*/
|
|
async function ensureDbAccessLogTable() {
|
|
try {
|
|
await db.query(`
|
|
CREATE TABLE IF NOT EXISTS db_access_log (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
query_type VARCHAR(20) NOT NULL,
|
|
tables_accessed JSONB,
|
|
query_text TEXT NOT NULL,
|
|
params_sanitized JSONB,
|
|
user_id UUID,
|
|
ip_address VARCHAR(45),
|
|
user_agent TEXT,
|
|
duration_ms INTEGER,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_db_access_log_created_at ON db_access_log(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_db_access_log_user_id ON db_access_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_db_access_log_tables ON db_access_log USING GIN(tables_accessed);
|
|
CREATE INDEX IF NOT EXISTS idx_db_access_log_query_type ON db_access_log(query_type);
|
|
`);
|
|
} catch (err) {
|
|
// Table might already exist or database might not be ready
|
|
// This is fine, the insert will handle it
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrap database query with logging
|
|
* @param {string} query - SQL query
|
|
* @param {Array} params - Query parameters
|
|
* @param {Object} context - Request context (optional)
|
|
* @returns {Promise} - Query result
|
|
*/
|
|
async function loggedQuery(query, params = [], context = {}) {
|
|
const startTime = Date.now();
|
|
const queryType = getQueryType(query);
|
|
const tables = extractTableNames(query);
|
|
|
|
// Check if we should log this query
|
|
if (!shouldLogQuery(query)) {
|
|
// Execute query without logging
|
|
return db.query(query, params);
|
|
}
|
|
|
|
try {
|
|
// Execute query
|
|
const result = await db.query(query, params);
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Log access (fire and forget)
|
|
logDbAccess({
|
|
query,
|
|
queryType,
|
|
tables,
|
|
params,
|
|
userId: context.userId || null,
|
|
ipAddress: context.ipAddress || null,
|
|
userAgent: context.userAgent || null,
|
|
duration,
|
|
}).catch(err => {
|
|
console.error('[dbAccessLogger] Failed to log (non-blocking):', err.message);
|
|
});
|
|
|
|
return result;
|
|
} catch (err) {
|
|
// Log failed query too
|
|
const duration = Date.now() - startTime;
|
|
logDbAccess({
|
|
query,
|
|
queryType,
|
|
tables,
|
|
params,
|
|
userId: context.userId || null,
|
|
ipAddress: context.ipAddress || null,
|
|
userAgent: context.userAgent || null,
|
|
duration,
|
|
error: err.message,
|
|
}).catch(() => {
|
|
// Ignore logging errors
|
|
});
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
loggedQuery,
|
|
logDbAccess,
|
|
shouldLogQuery,
|
|
isLoggingEnabled: () => LOGGING_ENABLED,
|
|
};
|
|
|