mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables Refine the Security page around the existing design system and add the data the dashboard needs. - Overview leads with four charts (30-day risk trend, severity donut, top exposed images, findings by type); the signal-rail counts become a secondary summary, and the scanner and deploy-enforcement posture follow. - Images becomes a recessed table with search, a severity filter, sortable columns, a last-scan column, and inline scan actions; the findings cell is clickable into the scan sheet, and the per-row cursor tooltip is dropped where the columns already carry that information. - Policies puts deploy-enforcement first, collapses the policy packs into an accordion, and uses the standard primary button for Add policy. - Suppressions and acknowledgements move their titles and Add buttons outside the cards, matching the Fleet tab layout. - History switches from the detail sheet to an inline table (search, sortable columns, two-scan compare, pagination); the now-unreachable scan-history overlay is removed. - Add GET /api/security/overview/trend, a node-scoped daily critical/high rollup backing the risk-trend chart. - Extract the shared image-scan hook and the severity classifier, and harden the overview data fetch so a malformed non-critical response can never read as a clean security state. * fix: treat malformed Security responses as errors, not empty or clean states Address an independent review of the data-fetch paths so a 200 with an unexpected shape can never read as a benign "no findings" view. - SecurityView: validate that the image-summaries body is a scan-summary map; an unexpected shape now sets the error state instead of an empty map. Isolate the trend fetch in its own self-catching promise so a transport failure on the non-critical chart can no longer poison the overview or summaries error state. - useImageScan: only a "completed" poll counts as success (a malformed or unknown status now throws), and a failed post-scan summaries refresh is logged instead of silently dropped. - HistoryTab: a 200 whose body lacks an items array is treated as an error, not an empty "no completed scans" list.
This commit is contained in:
@@ -58,6 +58,40 @@ function seedFailed(imageRef: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Midnight (UTC) `daysAgo` days back, so seeded times stay within one calendar day. */
|
||||
function dayStartMs(daysAgo: number): number {
|
||||
const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function seedCompleted(o: { imageRef: string; scannedAt: number; critical: number; high: number; nodeId?: number; status?: 'completed' | 'failed' }): void {
|
||||
db().createVulnerabilityScan({
|
||||
node_id: o.nodeId ?? 1,
|
||||
image_ref: o.imageRef,
|
||||
image_digest: `sha256:${o.imageRef}-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: o.scannedAt,
|
||||
total_vulnerabilities: o.critical + o.high,
|
||||
critical_count: o.critical,
|
||||
high_count: o.high,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
scanners_used: 'vuln',
|
||||
highest_severity: o.critical > 0 ? 'CRITICAL' : o.high > 0 ? 'HIGH' : null,
|
||||
os_info: null,
|
||||
trivy_version: null,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: o.status ?? 'completed',
|
||||
error: o.status === 'failed' ? 'boom' : null,
|
||||
stack_context: null,
|
||||
});
|
||||
}
|
||||
|
||||
function seedPolicy(overrides: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>): void {
|
||||
db().createScanPolicy({
|
||||
name: overrides.name ?? 'p',
|
||||
@@ -116,3 +150,33 @@ describe('countEligibleBlockPolicies (replica)', () => {
|
||||
expect(db().countEligibleBlockPolicies(1, 'replica', 'self-id')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDailyRiskTrend', () => {
|
||||
it('sums latest-per-image critical/high per day and orders days ascending', () => {
|
||||
const day1 = dayStartMs(3);
|
||||
const day2 = dayStartMs(2);
|
||||
// Day 1: imageA scanned twice; the later scan replaces the earlier one.
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 3_600_000, critical: 5, high: 2 });
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day1 + 7_200_000, critical: 3, high: 1 });
|
||||
seedCompleted({ imageRef: 'b:1', scannedAt: day1 + 3_600_000, critical: 1, high: 1 });
|
||||
// Day 2: a single image.
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day2 + 3_600_000, critical: 0, high: 4 });
|
||||
|
||||
const trend = db().getDailyRiskTrend(1, 30);
|
||||
expect(trend).toHaveLength(2);
|
||||
expect(trend[0]).toMatchObject({ critical: 4, high: 2 }); // latest a (3,1) + b (1,1)
|
||||
expect(trend[1]).toMatchObject({ critical: 0, high: 4 });
|
||||
expect(trend[0].date < trend[1].date).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes other nodes and non-completed scans', () => {
|
||||
const day = dayStartMs(1);
|
||||
seedCompleted({ imageRef: 'a:1', scannedAt: day + 3_600_000, critical: 2, high: 1 });
|
||||
seedCompleted({ imageRef: 'other:1', scannedAt: day + 3_600_000, critical: 9, high: 9, nodeId: 2 });
|
||||
seedCompleted({ imageRef: 'failed:1', scannedAt: day + 3_600_000, critical: 7, high: 7, status: 'failed' });
|
||||
|
||||
const trend = db().getDailyRiskTrend(1, 30);
|
||||
expect(trend).toHaveLength(1);
|
||||
expect(trend[0]).toMatchObject({ critical: 2, high: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,38 @@ describe('GET /api/security/overview', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/overview/trend', () => {
|
||||
beforeEach(() => resetSecurity());
|
||||
|
||||
const dayStart = (daysAgo: number): number => {
|
||||
const d = new Date(Date.now() - daysAgo * DAY);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
it('returns ascending daily critical/high points, node-scoped and completed only', async () => {
|
||||
const d1 = dayStart(3);
|
||||
const d2 = dayStart(2);
|
||||
seedScan({ image_ref: 'a:1', scanned_at: d1 + 3_600_000, critical: 4, high: 2 });
|
||||
seedScan({ image_ref: 'a:1', scanned_at: d2 + 3_600_000, critical: 1, high: 5 });
|
||||
seedScan({ node_id: 2, image_ref: 'x:1', scanned_at: d2 + 3_600_000, critical: 9, high: 9 }); // other node
|
||||
seedScan({ image_ref: 'f:1', scanned_at: d2 + 3_600_000, critical: 7, high: 7, status: 'failed' }); // failed
|
||||
|
||||
const res = await request(app).get('/api/security/overview/trend').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body[0]).toMatchObject({ critical: 4, high: 2 });
|
||||
expect(res.body[1]).toMatchObject({ critical: 1, high: 5 });
|
||||
expect(res.body[0].date < res.body[1].date).toBe(true);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/security/overview/trend');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/security/policy-packs', () => {
|
||||
it('returns the 5 default packs with fully-formed rules (auth-only)', async () => {
|
||||
const res = await request(app).get('/api/security/policy-packs').set('Cookie', adminCookie);
|
||||
|
||||
@@ -530,6 +530,22 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
});
|
||||
|
||||
// Daily Critical/High risk trend for the Security overview chart. Auth-only
|
||||
// (Community), node-scoped. ?days clamps to 1..365 in the DB layer.
|
||||
securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const days = req.query.days ? Number(req.query.days) : 30;
|
||||
const trend = DatabaseService.getInstance().getDailyRiskTrend(
|
||||
req.nodeId,
|
||||
Number.isFinite(days) ? days : 30,
|
||||
);
|
||||
res.json(trend);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to build risk trend:', error);
|
||||
res.status(500).json({ error: 'Failed to build risk trend' });
|
||||
}
|
||||
});
|
||||
|
||||
// Static, read-only policy-pack catalog. Auth-only (Community), no DB, no
|
||||
// enforcement. The frontend fetches this with localOnly so the global catalog
|
||||
// is available regardless of which node is active.
|
||||
|
||||
@@ -4515,6 +4515,46 @@ export class DatabaseService {
|
||||
).cnt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily Critical/High totals for the node over the last `days` days, for the
|
||||
* Security overview risk-trend chart. For each calendar day with scans, takes
|
||||
* the latest completed scan per image (so a re-scan replaces, not adds) and
|
||||
* sums the critical and high counts across images. Days with no scans are
|
||||
* omitted from the result.
|
||||
*/
|
||||
public getDailyRiskTrend(
|
||||
nodeId: number,
|
||||
days = 30,
|
||||
): Array<{ date: string; critical: number; high: number }> {
|
||||
const window = Math.max(1, Math.min(days, 365));
|
||||
const cutoffMs = Date.now() - window * 24 * 60 * 60 * 1000;
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`WITH daily_latest AS (
|
||||
SELECT
|
||||
DATE(scanned_at / 1000, 'unixepoch') AS day,
|
||||
image_ref,
|
||||
critical_count,
|
||||
high_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY DATE(scanned_at / 1000, 'unixepoch'), image_ref
|
||||
ORDER BY scanned_at DESC
|
||||
) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanned_at >= ?
|
||||
)
|
||||
SELECT day,
|
||||
SUM(critical_count) AS critical,
|
||||
SUM(high_count) AS high
|
||||
FROM daily_latest
|
||||
WHERE rn = 1
|
||||
GROUP BY day
|
||||
ORDER BY day ASC`,
|
||||
)
|
||||
.all(nodeId, cutoffMs) as Array<{ day: string; critical: number; high: number }>;
|
||||
return rows.map((r) => ({ date: r.day, critical: r.critical ?? 0, high: r.high ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of enabled block-on-deploy policies that are eligible to apply to
|
||||
* this node: fleet-wide (node_id IS NULL) or scoped to this node. Built on
|
||||
|
||||
Reference in New Issue
Block a user