From 5e01993120760c8a1ab1525f3ac0ac10dbef40ee Mon Sep 17 00:00:00 2001 From: empty Date: Sat, 27 Dec 2025 15:07:28 +0800 Subject: [PATCH] feat: add SLS toggle and sanitize logs --- .env.example | 1 + log-sanitizer.js | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ logger.js | 27 +++++++-------- server.js | 20 +++++++---- sls-logger.js | 28 +++++++++++++--- 5 files changed, 139 insertions(+), 24 deletions(-) create mode 100644 log-sanitizer.js diff --git a/.env.example b/.env.example index d7ce954..f1e10c0 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ FACTORY_API_KEY=your_factory_api_key_here DROID_REFRESH_KEY=your_refresh_token_here # 阿里云日志服务配置 +SLS_ENABLED=false ALIYUN_ACCESS_KEY_ID=your_access_key_id ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret ALIYUN_SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com diff --git a/log-sanitizer.js b/log-sanitizer.js new file mode 100644 index 0000000..98ec18e --- /dev/null +++ b/log-sanitizer.js @@ -0,0 +1,87 @@ +const REDACTION = '[REDACTED]'; + +const REDACT_KEY_RE = /(authorization|x-api-key|api[-_]?key|access_token|refresh_token|client_secret|private_key|set-cookie|cookie|password|secret)/i; +const EMAIL_KEY_RE = /email/i; +const IP_KEY_RE = /(^ip$|ip_address|remote_address|x-forwarded-for)/i; + +function maskEmail(value) { + if (typeof value !== 'string') return value; + return value.replace(/([A-Za-z0-9._%+-])([A-Za-z0-9._%+-]*)(@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g, '$1***$3'); +} + +function maskIp(value) { + if (typeof value !== 'string') return value; + let masked = value.replace(/\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b/g, '$1.xxx'); + masked = masked.replace(/\b([A-Fa-f0-9]{0,4}:){2,7}[A-Fa-f0-9]{0,4}\b/g, '****'); + return masked; +} + +function maskTokensInString(value) { + if (typeof value !== 'string') return value; + let masked = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+\b/g, 'Bearer ' + REDACTION); + masked = masked.replace(/\b(api_key|apikey|access_token|refresh_token|client_secret|password)=([^\s&]+)/gi, '$1=' + REDACTION); + return masked; +} + +function sanitizeString(value) { + if (typeof value !== 'string') return value; + let masked = value; + masked = maskTokensInString(masked); + masked = maskEmail(masked); + masked = maskIp(masked); + return masked; +} + +function sanitizeValue(value, key, seen) { + if (value === null || value === undefined) return value; + + if (key && REDACT_KEY_RE.test(key)) { + return REDACTION; + } + + if (typeof value === 'string') { + if (key && EMAIL_KEY_RE.test(key)) { + return maskEmail(value); + } + if (key && IP_KEY_RE.test(key)) { + return maskIp(value); + } + return sanitizeString(value); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.map(item => sanitizeValue(item, key, seen)); + } + + if (typeof value === 'object') { + return sanitizeObject(value, seen); + } + + return value; +} + +function sanitizeObject(value, seen) { + if (!value || typeof value !== 'object') return value; + if (!seen) seen = new WeakSet(); + if (seen.has(value)) return '[Circular]'; + seen.add(value); + + const output = Array.isArray(value) ? [] : {}; + for (const [key, val] of Object.entries(value)) { + output[key] = sanitizeValue(val, key, seen); + } + return output; +} + +export function sanitizeForLog(value) { + return sanitizeValue(value, null, new WeakSet()); +} + +export function sanitizeLogMessage(message) { + if (typeof message !== 'string') return message; + return sanitizeString(message); +} diff --git a/logger.js b/logger.js index fb400bc..04b5bea 100644 --- a/logger.js +++ b/logger.js @@ -1,28 +1,29 @@ import { isDevMode } from './config.js'; +import { sanitizeForLog, sanitizeLogMessage } from './log-sanitizer.js'; export function logInfo(message, data = null) { - console.log(`[INFO] ${message}`); + console.log(`[INFO] ${sanitizeLogMessage(message)}`); if (data && isDevMode()) { - console.log(JSON.stringify(data, null, 2)); + console.log(JSON.stringify(sanitizeForLog(data), null, 2)); } } export function logDebug(message, data = null) { if (isDevMode()) { - console.log(`[DEBUG] ${message}`); + console.log(`[DEBUG] ${sanitizeLogMessage(message)}`); if (data) { - console.log(JSON.stringify(data, null, 2)); + console.log(JSON.stringify(sanitizeForLog(data), null, 2)); } } } export function logError(message, error = null) { - console.error(`[ERROR] ${message}`); + console.error(`[ERROR] ${sanitizeLogMessage(message)}`); if (error) { if (isDevMode()) { - console.error(error); + console.error(sanitizeForLog(error)); } else { - console.error(error.message || error); + console.error(sanitizeLogMessage(error.message || String(error))); } } } @@ -30,16 +31,16 @@ export function logError(message, error = null) { export function logRequest(method, url, headers = null, body = null) { if (isDevMode()) { console.log(`\n${'='.repeat(80)}`); - console.log(`[REQUEST] ${method} ${url}`); + console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`); if (headers) { - console.log('[HEADERS]', JSON.stringify(headers, null, 2)); + console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2)); } if (body) { - console.log('[BODY]', JSON.stringify(body, null, 2)); + console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2)); } console.log('='.repeat(80) + '\n'); } else { - console.log(`[REQUEST] ${method} ${url}`); + console.log(`[REQUEST] ${sanitizeLogMessage(method)} ${sanitizeLogMessage(url)}`); } } @@ -48,10 +49,10 @@ export function logResponse(status, headers = null, body = null) { console.log(`\n${'-'.repeat(80)}`); console.log(`[RESPONSE] Status: ${status}`); if (headers) { - console.log('[HEADERS]', JSON.stringify(headers, null, 2)); + console.log('[HEADERS]', JSON.stringify(sanitizeForLog(headers), null, 2)); } if (body) { - console.log('[BODY]', JSON.stringify(body, null, 2)); + console.log('[BODY]', JSON.stringify(sanitizeForLog(body), null, 2)); } console.log('-'.repeat(80) + '\n'); } else { diff --git a/server.js b/server.js index e9e2a92..0070801 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ import router from './routes.js'; import { initializeAuth } from './auth.js'; import { initializeUserAgentUpdater } from './user-agent-updater.js'; import './sls-logger.js'; // 初始化阿里云日志服务 +import { sanitizeForLog } from './log-sanitizer.js'; const app = express(); @@ -58,6 +59,11 @@ app.use((req, res, next) => { ip: req.ip || req.connection.remoteAddress }; + const safeQuery = sanitizeForLog(errorInfo.query); + const safeBody = sanitizeForLog(errorInfo.body); + const safeHeaders = sanitizeForLog(errorInfo.headers); + const safeIp = sanitizeForLog(errorInfo.ip); + console.error('\n' + '='.repeat(80)); console.error('❌ 非法请求地址'); console.error('='.repeat(80)); @@ -67,23 +73,23 @@ app.use((req, res, next) => { console.error(`路径: ${errorInfo.path}`); if (Object.keys(errorInfo.query).length > 0) { - console.error(`查询参数: ${JSON.stringify(errorInfo.query, null, 2)}`); + console.error(`查询参数: ${JSON.stringify(safeQuery, null, 2)}`); } if (errorInfo.body && Object.keys(errorInfo.body).length > 0) { - console.error(`请求体: ${JSON.stringify(errorInfo.body, null, 2)}`); + console.error(`请求体: ${JSON.stringify(safeBody, null, 2)}`); } - console.error(`客户端IP: ${errorInfo.ip}`); - console.error(`User-Agent: ${errorInfo.headers['user-agent'] || 'N/A'}`); + console.error(`客户端IP: ${safeIp}`); + console.error(`User-Agent: ${safeHeaders['user-agent'] || 'N/A'}`); - if (errorInfo.headers.referer) { - console.error(`来源: ${errorInfo.headers.referer}`); + if (safeHeaders.referer) { + console.error(`来源: ${safeHeaders.referer}`); } console.error('='.repeat(80) + '\n'); - logError('Invalid request path', errorInfo); + logError('Invalid request path', sanitizeForLog(errorInfo)); res.status(404).json({ error: 'Not Found', diff --git a/sls-logger.js b/sls-logger.js index a3d93e5..c4018f3 100644 --- a/sls-logger.js +++ b/sls-logger.js @@ -8,6 +8,7 @@ */ import ALSClient from 'aliyun-log'; +import { sanitizeForLog } from './log-sanitizer.js'; // SLS 配置 const SLS_CONFIG = { @@ -18,8 +19,22 @@ const SLS_CONFIG = { logstore: process.env.ALIYUN_SLS_LOGSTORE }; +function resolveSlsEnabled() { + const raw = process.env.SLS_ENABLED; + if (raw === undefined || raw === null || String(raw).trim() === '') { + return process.env.NODE_ENV !== 'production'; + } + + const value = String(raw).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(value)) return true; + if (['0', 'false', 'no', 'off'].includes(value)) return false; + return process.env.NODE_ENV !== 'production'; +} + +const SLS_ENABLED = resolveSlsEnabled(); + // 检查配置是否完整 -const isConfigured = Object.values(SLS_CONFIG).every(v => v); +const isConfigured = SLS_ENABLED && Object.values(SLS_CONFIG).every(v => v); let client = null; let logQueue = []; @@ -28,6 +43,9 @@ const FLUSH_INTERVAL_MS = 5000; // 初始化 SLS Client function initClient() { + if (!SLS_ENABLED) { + return null; + } if (!isConfigured) { console.warn('[SLS] 阿里云日志服务未配置,日志将仅输出到控制台'); return null; @@ -73,7 +91,7 @@ async function flushLogs() { // 定时刷新 let flushTimer = null; function startFlushTimer() { - if (flushTimer || !isConfigured) return; + if (!SLS_ENABLED || flushTimer || !isConfigured) return; flushTimer = setInterval(flushLogs, FLUSH_INTERVAL_MS); } @@ -90,10 +108,11 @@ function startFlushTimer() { * @param {string} [logData.error] - 错误信息 */ export function logRequest(logData) { - const enrichedLog = { + if (!SLS_ENABLED) return; + const enrichedLog = sanitizeForLog({ timestamp: new Date().toISOString(), ...logData - }; + }); // 始终输出到控制台 console.log('[SLS]', JSON.stringify(enrichedLog)); @@ -112,6 +131,7 @@ export function logRequest(logData) { * 优雅关闭,刷新剩余日志 */ export async function shutdown() { + if (!SLS_ENABLED) return; if (flushTimer) { clearInterval(flushTimer); flushTimer = null;