mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
feat(security): fleet-replicated CVE suppression list (#650)
Operators can accept known-benign findings once and have Sencho filter them out of scan drawers, comparison views, and other read surfaces. Suppressions replicate from the control instance to every remote node. * New cve_suppressions table with a COALESCE-based unique index so NULL scope slots collide the way users expect * Admin + paid-tier CRUD routes; writes are rejected on replicas * Read-time filter enriches vulnerability details and compare payloads without mutating stored counts * Settings > Security panel for managing rules, per-CVE suppress action in the scan drawer, dimmed rows with a shield-off indicator * Vitest unit tests for the filter (glob, expiry, specificity) and route tests (auth, tier, replica, UNIQUE conflict)
This commit is contained in:
@@ -326,6 +326,18 @@ export interface FleetSyncStatus {
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface CveSuppression {
|
||||
id: number;
|
||||
cve_id: string;
|
||||
pkg_name: string | null;
|
||||
image_pattern: string | null;
|
||||
reason: string;
|
||||
created_by: string;
|
||||
created_at: number;
|
||||
expires_at: number | null;
|
||||
replicated_from_control: number;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
image_ref: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
@@ -633,6 +645,23 @@ export class DatabaseService {
|
||||
PRIMARY KEY (node_id, resource)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cve_suppressions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cve_id TEXT NOT NULL,
|
||||
pkg_name TEXT,
|
||||
image_pattern TEXT,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
replicated_from_control INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_cve ON cve_suppressions(cve_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_expires ON cve_suppressions(expires_at);
|
||||
-- COALESCE makes NULL scope slots collide the way users expect (NULL == NULL here).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_suppressions_unique
|
||||
ON cve_suppressions(cve_id, COALESCE(pkg_name, ''), COALESCE(image_pattern, ''));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -2515,6 +2544,97 @@ export class DatabaseService {
|
||||
.all(resource, cutoff) as FleetSyncStatus[];
|
||||
}
|
||||
|
||||
// --- CVE Suppressions ---
|
||||
|
||||
public getCveSuppressions(): CveSuppression[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM cve_suppressions ORDER BY cve_id, pkg_name')
|
||||
.all() as CveSuppression[];
|
||||
}
|
||||
|
||||
public getCveSuppression(id: number): CveSuppression | null {
|
||||
return (
|
||||
(this.db.prepare('SELECT * FROM cve_suppressions WHERE id = ?')
|
||||
.get(id) as CveSuppression | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public createCveSuppression(
|
||||
suppression: Omit<CveSuppression, 'id'>,
|
||||
): CveSuppression {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO cve_suppressions
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
suppression.cve_id,
|
||||
suppression.pkg_name,
|
||||
suppression.image_pattern,
|
||||
suppression.reason,
|
||||
suppression.created_by,
|
||||
suppression.created_at,
|
||||
suppression.expires_at,
|
||||
suppression.replicated_from_control ?? 0,
|
||||
);
|
||||
return { ...suppression, id: result.lastInsertRowid as number };
|
||||
}
|
||||
|
||||
public updateCveSuppression(
|
||||
id: number,
|
||||
updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at'>>,
|
||||
): CveSuppression | null {
|
||||
const existing = this.getCveSuppression(id);
|
||||
if (!existing) return null;
|
||||
const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at']);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!ALLOWED.has(key)) continue;
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
if (fields.length === 0) return existing;
|
||||
values.push(id);
|
||||
this.db
|
||||
.prepare(`UPDATE cve_suppressions SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.run(...(values as never[]));
|
||||
return this.getCveSuppression(id);
|
||||
}
|
||||
|
||||
public deleteCveSuppression(id: number): void {
|
||||
this.db.prepare('DELETE FROM cve_suppressions WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all replicated CVE suppressions in a single transaction.
|
||||
* Preserves rows flagged as locally created on this instance.
|
||||
*/
|
||||
public replaceReplicatedCveSuppressions(rows: Array<Omit<CveSuppression, 'id'>>): void {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1');
|
||||
const insertStmt = this.db.prepare(
|
||||
`INSERT INTO cve_suppressions
|
||||
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
);
|
||||
const txn = this.db.transaction((items: Array<Omit<CveSuppression, 'id'>>) => {
|
||||
deleteStmt.run();
|
||||
for (const s of items) {
|
||||
insertStmt.run(
|
||||
s.cve_id,
|
||||
s.pkg_name,
|
||||
s.image_pattern,
|
||||
s.reason,
|
||||
s.created_by,
|
||||
s.created_at,
|
||||
s.expires_at,
|
||||
);
|
||||
}
|
||||
});
|
||||
txn(rows);
|
||||
}
|
||||
|
||||
// --- Stack Labels ---
|
||||
|
||||
public getLabels(nodeId: number): Label[] {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { DatabaseService, Node, ScanPolicy } from './DatabaseService';
|
||||
import { CveSuppression, DatabaseService, Node, ScanPolicy } from './DatabaseService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
|
||||
export type FleetResource = 'scan_policies';
|
||||
export type FleetResource = 'scan_policies' | 'cve_suppressions';
|
||||
|
||||
export const FLEET_RESOURCES: readonly FleetResource[] = ['scan_policies', 'cve_suppressions'];
|
||||
|
||||
export function isFleetResource(value: unknown): value is FleetResource {
|
||||
return typeof value === 'string' && (FLEET_RESOURCES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type FleetRole = 'control' | 'replica';
|
||||
|
||||
@@ -140,14 +146,20 @@ export class FleetSyncService {
|
||||
* This promotes the instance to 'replica' mode if not already, caches
|
||||
* the target identity it was told, and replaces replicated rows atomically.
|
||||
*/
|
||||
public applyIncomingSync(resource: FleetResource, rows: ScanPolicy[], targetIdentity: string): void {
|
||||
public applyIncomingSync(
|
||||
resource: FleetResource,
|
||||
rows: ScanPolicy[] | Array<Omit<CveSuppression, 'id'>>,
|
||||
targetIdentity: string,
|
||||
): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.setSystemState('fleet_role', 'replica');
|
||||
if (targetIdentity) {
|
||||
db.setSystemState('fleet_self_identity', targetIdentity);
|
||||
}
|
||||
if (resource === 'scan_policies') {
|
||||
db.replaceReplicatedScanPolicies(rows);
|
||||
db.replaceReplicatedScanPolicies(rows as ScanPolicy[]);
|
||||
} else if (resource === 'cve_suppressions') {
|
||||
db.replaceReplicatedCveSuppressions(rows as Array<Omit<CveSuppression, 'id'>>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +180,20 @@ export class FleetSyncService {
|
||||
updated_at: p.updated_at,
|
||||
}));
|
||||
}
|
||||
if (resource === 'cve_suppressions') {
|
||||
return db
|
||||
.getCveSuppressions()
|
||||
.filter((s) => s.replicated_from_control === 0)
|
||||
.map((s) => ({
|
||||
cve_id: s.cve_id,
|
||||
pkg_name: s.pkg_name,
|
||||
image_pattern: s.image_pattern,
|
||||
reason: s.reason,
|
||||
created_by: s.created_by,
|
||||
created_at: s.created_at,
|
||||
expires_at: s.expires_at,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user