优化后端鉴权与日志安全

This commit is contained in:
yml2213
2026-05-26 08:21:11 +08:00
parent 851d1d1efc
commit dea3023825
35 changed files with 402 additions and 185 deletions
+6 -3
View File
@@ -29,13 +29,16 @@ export function buildSuccessPayload(data: unknown, msg: string = "ok") {
export function buildErrorPayload(error: unknown, fallbackMessage: string) {
const classification = classifyRouteError(error);
const message = classification.statusCode >= 500
? fallbackMessage || "服务内部错误"
: error instanceof Error ? error.message : fallbackMessage;
return {
code: 1,
msg: error instanceof Error ? error.message : fallbackMessage,
msg: message,
errorCode: classification.errorCode,
time: Math.floor(Date.now() / 1000),
data: null,
data: null as null,
};
}
@@ -62,7 +65,7 @@ export function buildNotFoundPayload(req: Request) {
code: 1,
msg: `未实现接口: ${req.method} ${req.originalUrl}`,
time: Math.floor(Date.now() / 1000),
data: null,
data: null as null,
};
}
+24
View File
@@ -102,3 +102,27 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod
assert.match(text, /未登录或登录已失效/)
assert.doesNotMatch(text, /\u001B\[/)
})
test('formatLogEntry masks sensitive fields in inline and nested details', () => {
const text = formatLogEntry({
time: '2026-04-14T10:36:24.974Z',
level: 'info',
scope: '[security]',
message: 'masked',
pid: 4671,
detail: {
token: 'abcdef1234567890',
authorization: 'Bearer abcdef1234567890',
nested: {
cookie: 'sessionid=abcdef1234567890',
note: 'password=super-secret-value',
},
},
}, { color: false })
assert.doesNotMatch(text, /abcdef1234567890/)
assert.doesNotMatch(text, /super-secret-value/)
assert.match(text, /abcdef\*\*\*\*567890/)
assert.match(text, /Bearer\*\*\*\*567890/)
assert.match(text, /password=super-\*\*\*\*-value/)
})
+140 -50
View File
@@ -5,15 +5,29 @@ import util from 'node:util'
import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js'
import type { HttpErrorLike } from './http.js'
import { maskSecret } from './masking.js'
const LOG_LEVEL_PRIORITY = {
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
type LogChannel = 'app' | 'integration'
type LogDetail = unknown
type InlineFields = Record<string, string | number | boolean | null | undefined>
type LogEntry = {
time: string
level: LogLevel
scope: string
message: string
pid: number
detail?: unknown
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
}
const LEVEL_LABELS = {
const LEVEL_LABELS: Record<LogLevel, string> = {
debug: 'DEBUG',
info: 'INFO ',
warn: 'WARN ',
@@ -36,35 +50,48 @@ const LOG_DIR = path.join(DATA_ROOT, 'logs')
const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
const LOG_RETENTION_DAYS = 7
const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log'])
const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i
const MAX_SANITIZE_DEPTH = 8
let writeQueue = Promise.resolve()
let lastCleanupDateKey = ''
export function createRequestId(prefix = 'req') {
export function createRequestId(prefix = 'req'): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
export function logDebug(scope, message, detail = undefined) {
export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) {
return writeLog('debug', scope, message, detail)
}
export function logInfo(scope, message, detail = undefined) {
export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) {
return writeLog('info', scope, message, detail)
}
export function logWarn(scope, message, detail = undefined) {
export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) {
return writeLog('warn', scope, message, detail)
}
export function logError(scope, message, detail = undefined) {
export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) {
return writeLog('error', scope, message, detail)
}
export function logIntegration(scope, message, detail = undefined, { level = 'info' } = {}) {
export function logIntegration(
scope: unknown,
message: unknown,
detail: LogDetail = undefined,
{ level = 'info' }: { level?: LogLevel } = {},
) {
return writeLog(level, scope, message, detail, { channel: 'integration' })
}
function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
function writeLog(
level: LogLevel,
scope: unknown,
message: unknown,
detail: LogDetail,
{ channel = 'app' }: { channel?: LogChannel } = {},
): LogEntry | null {
if (!shouldWriteLog(level, ACTIVE_LOG_LEVEL)) {
return null
}
@@ -83,7 +110,7 @@ function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
return entry
}
function enqueueFileWrite(entry, filePath) {
function enqueueFileWrite(entry: LogEntry, filePath: string): void {
const line = formatLogEntry(entry, { color: false })
const dateKey = extractLogDateKey(entry.time)
@@ -99,12 +126,12 @@ function enqueueFileWrite(entry, filePath) {
})
}
function writeConsole(entry) {
function writeConsole(entry: LogEntry): void {
const logger = resolveConsoleMethod(entry.level)
logger(formatLogEntry(entry, { color: supportsAnsiColor() }))
}
function resolveConsoleMethod(level) {
function resolveConsoleMethod(level: unknown) {
const normalized = normalizeLogLevel(level)
if (normalized === 'error') {
@@ -118,26 +145,26 @@ function resolveConsoleMethod(level) {
return console.log
}
export function normalizeLogLevel(level) {
export function normalizeLogLevel(level: unknown): LogLevel {
const normalized = String(level || '').trim().toLowerCase()
return LOG_LEVEL_PRIORITY[normalized] != null ? normalized : 'info'
return isLogLevel(normalized) ? normalized : 'info'
}
export function shouldWriteLog(level, configuredLevel = ACTIVE_LOG_LEVEL) {
export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean {
const normalizedLevel = normalizeLogLevel(level)
const normalizedConfigured = normalizeLogLevel(configuredLevel)
return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured]
}
export function formatLogEntry(entry, { color = false } = {}) {
export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { color?: boolean } = {}): string {
const normalizedEntry = {
time: String(entry?.time || new Date().toISOString()),
level: normalizeLogLevel(entry?.level),
scope: String(entry?.scope || 'app').trim() || 'app',
message: String(entry?.message || '').trim() || '-',
pid: Number(entry?.pid || process.pid),
detail: entry?.detail,
detail: sanitizeLogValue(entry?.detail),
}
const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color)
const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color)
@@ -166,16 +193,16 @@ export function formatLogEntry(entry, { color = false } = {}) {
return `${firstLine}\n${indentBlock(inspected, ' ')}`
}
export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) {
export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string {
const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app'
return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`)
}
export function resolveExpiredLogFilenames(
fileNames = [],
fileNames: string[] = [],
referenceTime = new Date().toISOString(),
retentionDays = LOG_RETENTION_DAYS,
) {
): string[] {
const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS)
const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1)))
@@ -183,8 +210,8 @@ export function resolveExpiredLogFilenames(
.filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey))
}
export function formatLogTimestamp(value) {
const date = new Date(value)
export function formatLogTimestamp(value: unknown): string {
const date = new Date(value instanceof Date ? value : String(value || ''))
if (Number.isNaN(date.getTime())) {
return String(value || '').trim() || new Date().toISOString()
@@ -193,27 +220,28 @@ export function formatLogTimestamp(value) {
return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}`
}
function resolveDataRoot() {
function resolveDataRoot(): string {
const configured = String(runtimeConfig.data?.root || '').trim()
return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data')
}
function normalizeLogValue(value) {
function normalizeLogValue(value: unknown): unknown {
if (typeof value === 'undefined') {
return undefined
}
try {
return JSON.parse(
return sanitizeLogValue(JSON.parse(
JSON.stringify(value, (_key, current) => {
if (current instanceof Error) {
const currentError = current as Error & HttpErrorLike
return {
name: current.name,
message: current.message,
stack: current.stack,
message: sanitizeLogString(current.message),
stack: sanitizeLogString(current.stack),
statusCode: currentError.statusCode,
errorCode: currentError.errorCode,
context: sanitizeLogValue(currentError.context),
}
}
@@ -223,13 +251,71 @@ function normalizeLogValue(value) {
return current
}),
)
))
} catch {
return String(value)
return sanitizeLogString(String(value))
}
}
function splitDetailPayload(detail) {
function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown {
if (typeof value === 'undefined') {
return undefined
}
if (value === null) {
return null
}
if (isSensitiveLogKey(key)) {
return maskSecret(value)
}
if (typeof value === 'string') {
return sanitizeLogString(value)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value
}
if (typeof value === 'bigint') {
return String(value)
}
if (depth >= MAX_SANITIZE_DEPTH) {
return '[MaxDepth]'
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeLogValue(item, key, depth + 1))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([currentKey, currentValue]) => [
currentKey,
sanitizeLogValue(currentValue, currentKey, depth + 1),
]),
)
}
return sanitizeLogString(String(value))
}
function sanitizeLogString(value: unknown): string {
return String(value || '')
.replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`)
.replace(
/\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi,
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
)
}
function isSensitiveLogKey(key: unknown): boolean {
return SENSITIVE_KEY_PATTERN.test(String(key || '').trim())
}
function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } {
if (typeof detail === 'undefined') {
return {
inline: {},
@@ -252,8 +338,8 @@ function splitDetailPayload(detail) {
}
if (detail && typeof detail === 'object') {
const inline = {}
const block = {}
const inline: InlineFields = {}
const block: Record<string, unknown> = {}
for (const [key, value] of Object.entries(detail)) {
if (isScalarLogValue(value)) {
@@ -276,14 +362,14 @@ function splitDetailPayload(detail) {
}
}
function formatInlineFields(fields) {
function formatInlineFields(fields: InlineFields): string {
return Object.entries(fields)
.filter(([, value]) => typeof value !== 'undefined' && value !== '')
.map(([key, value]) => `${key}=${formatInlineValue(value)}`)
.join(' ')
}
function formatInlineValue(value) {
function formatInlineValue(value: unknown): string {
if (value === null) {
return 'null'
}
@@ -296,22 +382,22 @@ function formatInlineValue(value) {
return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text)
}
function isScalarLogValue(value) {
function isScalarLogValue(value: unknown): value is string | number | boolean | null {
return value === null || ['string', 'number', 'boolean'].includes(typeof value)
}
function indentBlock(text, indent) {
function indentBlock(text: unknown, indent: string): string {
return String(text || '')
.split('\n')
.map((line) => `${indent}${line}`)
.join('\n')
}
function supportsAnsiColor() {
function supportsAnsiColor(): boolean {
return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY)
}
function resolveLevelColor(level) {
function resolveLevelColor(level: unknown): string {
switch (normalizeLogLevel(level)) {
case 'debug':
return ANSI.blue
@@ -324,7 +410,7 @@ function resolveLevelColor(level) {
}
}
function colorize(text, ansiCode, enabled) {
function colorize(text: string, ansiCode: string, enabled: boolean): string {
if (!enabled || !ansiCode) {
return text
}
@@ -332,15 +418,15 @@ function colorize(text, ansiCode, enabled) {
return `${ansiCode}${text}${ANSI.reset}`
}
function formatLocalDate(date) {
function formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function formatLocalTime(date) {
function formatLocalTime(date: Date): string {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
function formatTimezoneOffset(date) {
function formatTimezoneOffset(date: Date): string {
const totalMinutes = -date.getTimezoneOffset()
const sign = totalMinutes >= 0 ? '+' : '-'
const absoluteMinutes = Math.abs(totalMinutes)
@@ -349,7 +435,7 @@ function formatTimezoneOffset(date) {
return `${sign}${hours}:${minutes}`
}
async function cleanupExpiredLogsIfNeeded(dateKey) {
async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise<void> {
if (!dateKey || dateKey === lastCleanupDateKey) {
return
}
@@ -366,13 +452,13 @@ async function cleanupExpiredLogsIfNeeded(dateKey) {
}
}
function extractLogDateKey(time) {
function extractLogDateKey(time: unknown): string {
const normalized = String(time || '').trim()
return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized)
}
function toDateKey(value) {
const date = new Date(value)
function toDateKey(value: unknown): string {
const date = new Date(value instanceof Date ? value : String(value || ''))
if (Number.isNaN(date.getTime())) {
return new Date().toISOString().slice(0, 10)
@@ -381,13 +467,13 @@ function toDateKey(value) {
return date.toISOString().slice(0, 10)
}
function offsetDate(value, offsetDays) {
const date = new Date(value)
function offsetDate(value: unknown, offsetDays: unknown): Date {
const date = new Date(value instanceof Date ? value : String(value || ''))
date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0))
return date
}
function shouldDeleteLogFileByDate(fileName, cutoffDateKey) {
function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean {
if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) {
return true
}
@@ -398,5 +484,9 @@ function shouldDeleteLogFileByDate(fileName, cutoffDateKey) {
return false
}
return matched[2] < cutoffDateKey
return String(matched[2] || '') < cutoffDateKey
}
function isLogLevel(value: string): value is LogLevel {
return ['debug', 'info', 'warn', 'error'].includes(value)
}