/** * Audit Logger * Logs content security audit events with content desensitization. * Logs are retained for at least 180 days. * * Features: * - Content desensitization: only first 3 and last 3 chars kept, middle replaced with *** * - Access Token never logged in plaintext * - Structured JSON log format * - Automatic log rotation */ const fs = require('fs'); const path = require('path'); // ============================================================ // Configuration // ============================================================ const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit'); const LOG_RETENTION_DAYS = 180; const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily class AuditLogger { constructor() { this._ensureLogDir(); this._currentLogFile = this._getLogFilePath(new Date()); this._rotationTimer = null; this._startRotation(); } /** * Log an audit event. * @param {object} entry * @param {string} entry.userId - User identifier (openid) * @param {string} entry.contentType - 'text' or 'image' * @param {string} entry.contentSummary - Desensitized content summary * @param {number} entry.scene - Scene value * @param {string} entry.result - 'pass', 'reject', 'error', 'rejected' * @param {string} entry.reason - Reason for the result * @param {number} [entry.duration] - Duration of the check in ms */ logAudit(entry) { const logEntry = { timestamp: new Date().toISOString(), userId: entry.userId || 'unknown', contentType: entry.contentType || 'unknown', contentSummary: this._sanitizeContent(entry.contentSummary), scene: entry.scene || 0, result: entry.result || 'unknown', reason: entry.reason || '', duration: entry.duration || 0, }; const logLine = JSON.stringify(logEntry) + '\n'; try { fs.appendFileSync(this._currentLogFile, logLine, 'utf8'); } catch (err) { console.error('[AuditLogger] Failed to write log:', err.message); } } /** * Log a violation event. * @param {object} entry * @param {string} entry.userId - User identifier * @param {string} entry.violationType - Type of violation * @param {string} entry.contentSummary - Desensitized content * @param {string} entry.action - Action taken (e.g., 'mute_24h') */ logViolation(entry) { const logEntry = { timestamp: new Date().toISOString(), type: 'violation', userId: entry.userId || 'unknown', violationType: entry.violationType || 'unknown', contentSummary: this._sanitizeContent(entry.contentSummary), action: entry.action || '', }; const logLine = JSON.stringify(logEntry) + '\n'; try { fs.appendFileSync(this._currentLogFile, logLine, 'utf8'); } catch (err) { console.error('[AuditLogger] Failed to write violation log:', err.message); } } /** * Log a report event. * @param {object} entry * @param {string} entry.reporterId - Reporter's user ID (kept confidential) * @param {string} entry.targetUserId - Reported user's ID * @param {string} entry.contentSummary - Desensitized reported content * @param {string} entry.reason - Report reason */ logReport(entry) { const logEntry = { timestamp: new Date().toISOString(), type: 'report', reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential targetUserId: entry.targetUserId || 'unknown', contentSummary: this._sanitizeContent(entry.contentSummary), reason: entry.reason || '', }; const logLine = JSON.stringify(logEntry) + '\n'; try { fs.appendFileSync(this._currentLogFile, logLine, 'utf8'); } catch (err) { console.error('[AuditLogger] Failed to write report log:', err.message); } } /** * Ensure the log directory exists. */ _ensureLogDir() { try { if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } } catch (err) { console.error('[AuditLogger] Failed to create log directory:', err.message); } } /** * Get the log file path for a given date. * @param {Date} date * @returns {string} */ _getLogFilePath(date) { const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD return path.join(LOG_DIR, `audit-${dateStr}.log`); } /** * Start daily log rotation. */ _startRotation() { this._rotationTimer = setInterval(() => { const newLogFile = this._getLogFilePath(new Date()); if (newLogFile !== this._currentLogFile) { this._currentLogFile = newLogFile; console.log('[AuditLogger] Rotated to new log file:', newLogFile); } // Clean up old logs this._cleanOldLogs(); }, LOG_ROTATION_INTERVAL_MS); } /** * Remove log files older than the retention period. */ _cleanOldLogs() { try { const files = fs.readdirSync(LOG_DIR); const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; for (const file of files) { const filePath = path.join(LOG_DIR, file); const stat = fs.statSync(filePath); if (stat.birthtimeMs < cutoff) { fs.unlinkSync(filePath); console.log('[AuditLogger] Deleted old log file:', file); } } } catch (err) { console.error('[AuditLogger] Failed to clean old logs:', err.message); } } /** * Sanitize content: keep only first 3 and last 3 chars, replace middle with ***. * @param {string} content * @returns {string} */ _sanitizeContent(content) { if (!content || typeof content !== 'string') return ''; if (content.length <= 6) return content; return content.substring(0, 3) + '***' + content.substring(content.length - 3); } /** * Clean up resources. */ destroy() { if (this._rotationTimer) { clearInterval(this._rotationTimer); this._rotationTimer = null; } console.log('[AuditLogger] Destroyed'); } } module.exports = AuditLogger;