mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
42e8d3a78c
* feat(security): per-image scroll + retention cap in scan history Long scan histories for hot images used to monopolise the Scan history sheet: a single image with dozens of scans pushed every other image off screen, and the underlying vulnerability_scans table grew without bound. Each image group's table now renders inside its own ScrollArea capped at max-h-64 (~6 rows visible) so a busy image scrolls independently while the list of images stays navigable. A new global setting scan_history_per_image_limit (default 50, min 5, max 1000) backs both a window-function query that caps the response per image_ref and a prune step that runs on the existing MonitorService cleanup tick. The response now carries cappedImageRefs + perImageLimit so the UI can render a "Capped at N · older scans pruned" hint on groups sitting at the ceiling without a second settings round-trip. Single-image deep-dive (imageRef query param) bypasses the cap so a user clicking into one image can still see its full history. The prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER issues on first-run installs with large backlogs, and explicitly deletes child rows from vulnerability_details, secret_findings, and misconfig_findings inside a transaction since FK cascade is not enabled at the connection level. Settings → Developer → Data retention gains a "Scan history per image" field. * fix(security): skip searchDraft debounce on mount to stop page-reset race The searchDraft debounce useEffect fires once on initial mount with the unchanged value and, 300ms later, unconditionally calls setPage(0). When a user (or a test) paginates inside that 300ms window, the pending debounce silently undoes the page advance. CI surfaced this as a flaky 3rd fetch in the "advances offset when the user pages forward" test once the per-image cap work added enough state-update overhead to push the click past the 300ms threshold on the slower Linux jsdom run. Track searchDraft with a ref and exit the effect when the value has not actually changed, so the debounce only runs in response to real user typing.
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { z } from 'zod';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requireAdmin } from '../middleware/tierGates';
|
|
|
|
// Keys that contain auth credentials; never exposed to the frontend or
|
|
// writable via the settings API.
|
|
const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']);
|
|
|
|
// Strict allowlist of keys writable via the settings API. Prevents
|
|
// overwriting auth credentials through a misconfigured key.
|
|
const ALLOWED_SETTING_KEYS = new Set([
|
|
'host_cpu_limit',
|
|
'host_ram_limit',
|
|
'host_disk_limit',
|
|
'host_alert_suppression_mins',
|
|
'docker_janitor_gb',
|
|
'global_crash',
|
|
'developer_mode',
|
|
'template_registry_url',
|
|
'metrics_retention_hours',
|
|
'log_retention_days',
|
|
'audit_retention_days',
|
|
'mesh_auto_recreate',
|
|
'scan_history_per_image_limit',
|
|
]);
|
|
|
|
// Bulk PATCH schema. All keys optional; present keys are fully validated.
|
|
const SettingsPatchSchema = z.object({
|
|
host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_alert_suppression_mins: z.coerce.number().int().min(1).max(1440).transform(String),
|
|
docker_janitor_gb: z.coerce.number().min(0).transform(String),
|
|
global_crash: z.enum(['0', '1']),
|
|
developer_mode: z.enum(['0', '1']),
|
|
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
|
|
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
|
|
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
|
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
|
mesh_auto_recreate: z.enum(['0', '1']),
|
|
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
|
|
}).partial();
|
|
|
|
export const settingsRouter = Router();
|
|
|
|
settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const settings = { ...DatabaseService.getInstance().getGlobalSettings() };
|
|
for (const key of PRIVATE_SETTINGS_KEYS) {
|
|
delete settings[key];
|
|
}
|
|
res.json(settings);
|
|
} catch (error) {
|
|
console.error('Failed to fetch settings:', error);
|
|
res.status(500).json({ error: 'Failed to fetch settings' });
|
|
}
|
|
});
|
|
|
|
settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const { key, value } = req.body;
|
|
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
|
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
|
return;
|
|
}
|
|
if (value === undefined || value === null) {
|
|
res.status(400).json({ error: 'Setting value is required' });
|
|
return;
|
|
}
|
|
// Route the single-key write through the same per-key schema used by
|
|
// the bulk PATCH so allowlisted-but-malformed values (e.g. `true`,
|
|
// `banana`, out-of-range integers) cannot bypass validation just
|
|
// because they came in via the single-key path. The schema coerces
|
|
// numeric settings to strings and rejects enum-shaped settings that
|
|
// are not one of the allowed literals.
|
|
const parsed = SettingsPatchSchema.safeParse({ [key]: value });
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
error: 'Validation failed',
|
|
details: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
const validated = (parsed.data as Record<string, string>)[key];
|
|
if (validated === undefined) {
|
|
// Defensive: the schema is `.partial()`, so an unknown key would
|
|
// pass through silently. We already gated on ALLOWED_SETTING_KEYS,
|
|
// but reject explicitly if the key is somehow missing from the
|
|
// schema's shape (drift between the allowlist and the schema).
|
|
res.status(400).json({ error: `Setting key has no validator: ${key}` });
|
|
return;
|
|
}
|
|
DatabaseService.getInstance().updateGlobalSetting(key, validated);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update setting:', error);
|
|
res.status(500).json({ error: 'Failed to update setting' });
|
|
}
|
|
});
|
|
|
|
settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const parsed = SettingsPatchSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
|
return;
|
|
}
|
|
const db = DatabaseService.getInstance();
|
|
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
|
|
for (const [k, v] of entries) {
|
|
db.updateGlobalSetting(k, v);
|
|
}
|
|
});
|
|
updateMany(Object.entries(parsed.data) as [string, string][]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to bulk update settings:', error);
|
|
res.status(500).json({ error: 'Failed to update settings' });
|
|
}
|
|
});
|