Files
sencho/backend/src/routes/auditLog.ts
T
Anso 4e5ba17710 refactor(backend): sanitize user input before logging to close CRLF injection (#807)
* refactor(backend): sanitize user input before logging to close CRLF injection

Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.

Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string

The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.

* refactor(backend): printf-style format strings for tainted-log call sites

CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.

Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).

No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.

* fix(backend): wrap nodeId/snapshotId in fleet restore debug log

CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
2026-04-27 10:47:23 -04:00

106 lines
4.8 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from '../services/AuditAnomalyService';
import { requireAdmiral } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { isDebugEnabled } from '../utils/debug';
import { escapeCsvField } from '../utils/csv';
import { sanitizeForLog } from '../utils/safeLog';
export const auditLogRouter = Router();
auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
const withAnomalies = req.query.with_anomalies === '1';
if (isDebugEnabled()) {
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${sanitizeForLog(username || '-')} method=${sanitizeForLog(method || '-')} search=${sanitizeForLog(search || '-')}`);
}
const db = DatabaseService.getInstance();
const result = db.getAuditLogs({ page, limit, username, method, from, to, search });
if (withAnomalies && result.entries.length > 0) {
const now = Date.now();
const historyFrom = now - HISTORY_WINDOW_MS;
const oldestInPage = result.entries.reduce(
(min, e) => Math.min(min, e.timestamp),
result.entries[0].timestamp,
);
const history = db.getAuditLogsInRange(historyFrom, oldestInPage);
res.json({ ...result, entries: annotateEntries(result.entries, history, now) });
return;
}
res.json(result);
} catch (error) {
console.error('[AuditLog] Failed to fetch audit log:', error);
res.status(500).json({ error: 'Failed to fetch audit log' });
}
});
auditLogRouter.get('/stats', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const now = Date.now();
const db = DatabaseService.getInstance();
const cutoff24h = now - 24 * 60 * 60 * 1000;
const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
const last30d = db.getAuditLogsInRange(now - HISTORY_WINDOW_MS, now);
const last7d = last30d.filter(e => e.timestamp >= cutoff7d);
const last24h = last7d.filter(e => e.timestamp >= cutoff24h);
res.json(computeAuditStats({ now, last24h, last7d, last30d }));
} catch (error) {
console.error('[AuditLog] Failed to compute audit stats:', error);
res.status(500).json({ error: 'Failed to compute audit stats' });
}
});
auditLogRouter.get('/export', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmiral(req, res)) return;
if (!requirePermission(req, res, 'system:audit')) return;
try {
const format = (req.query.format as string) === 'csv' ? 'csv' : 'json';
const username = req.query.username as string | undefined;
const method = req.query.method as string | undefined;
const search = req.query.search as string | undefined;
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
if (isDebugEnabled()) {
console.log(`[Audit:diag] Export: format=${format} filters=${JSON.stringify({ username, method, search, from, to })}`);
}
const result = DatabaseService.getInstance().getAuditLogs({ page: 1, limit: 10000, username, method, from, to, search });
const timestamp = new Date().toISOString().slice(0, 10);
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.json"`);
res.json(result.entries);
} else {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-log-${timestamp}.csv"`);
const headers = ['id', 'timestamp', 'username', 'method', 'path', 'status_code', 'node_id', 'ip_address', 'summary'];
const rows = result.entries.map(e =>
headers.map(h => escapeCsvField(e[h as keyof typeof e])).join(','),
);
res.send([headers.join(','), ...rows].join('\n'));
}
} catch (error) {
console.error('[AuditLog] Export failed:', error);
res.status(500).json({ error: 'Failed to export audit log' });
}
});