mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
4e5ba17710
* 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.
106 lines
4.8 KiB
TypeScript
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' });
|
|
}
|
|
});
|