Files
sencho/backend/src/routes/settings.ts
T
Anso 42e8d3a78c feat(security): per-image scroll + retention cap in scan history (#1231)
* 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.
2026-05-25 23:44:31 -04:00

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' });
}
});