mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
fix: name matched risk inputs in policy scan banner and alerts (#1473)
The pre-deploy gate names the inputs that matched a scan policy (a known-exploited CVE, a fixable Critical/High, or a severity threshold), but the informational post-scan surfaces still framed every violation as a severity ceiling. The scan detail banner read "blocks severities at or above X, highest severity is Y" and the scheduled-scan alert read "<severity> exceeds <maxSeverity>", which is wrong for a KEV- or fixable-only policy that never gated on severity. Persist the matched reasons on the policy evaluation, carry them on the scheduled-scan violation, and render them on the banner so every policy surface names the input that actually matched. Evaluations persisted before this change carry no reasons: the parser defaults the field to an empty array and the banner falls back to a plain violation notice.
This commit is contained in:
@@ -10,10 +10,11 @@ import type { VulnerabilityScan, VulnerabilityDetail } from '../services/Databas
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let parsePolicyEvaluation: typeof import('../services/DatabaseService').parsePolicyEvaluation;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ DatabaseService, parsePolicyEvaluation } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -126,4 +127,39 @@ describe('evaluateScanAgainstPolicies risk inputs', () => {
|
||||
const cleanScan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-9200', severity: 'CRITICAL' })]);
|
||||
expect(db.evaluateScanAgainstPolicies(1, cleanScan, '')!.violated).toBe(false);
|
||||
});
|
||||
|
||||
it('records the matched inputs as reasons so the banner can name them', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 });
|
||||
db.replaceKev([{ cve_id: 'CVE-2026-9100', date_added: '2026-01-01' }], Date.now());
|
||||
const scan = seedScan('web', 'CRITICAL', [
|
||||
detail({ vulnerability_id: 'CVE-2026-9100', severity: 'CRITICAL', fixed_version: '2.0' }),
|
||||
]);
|
||||
expect(db.evaluateScanAgainstPolicies(1, scan, '')!.reasons).toEqual(['kev', 'fixable']);
|
||||
});
|
||||
|
||||
it('records ["severity"] for a severity-only violation and [] when within limits', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', max_severity: 'HIGH' });
|
||||
expect(db.evaluateScanAgainstPolicies(1, seedScan('web', 'CRITICAL', []), '')!.reasons).toEqual(['severity']);
|
||||
expect(db.evaluateScanAgainstPolicies(1, seedScan('web', 'LOW', []), '')!.reasons).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePolicyEvaluation', () => {
|
||||
const base = { policyId: 1, policyName: 'p', maxSeverity: 'HIGH', violated: true };
|
||||
|
||||
it('defaults reasons to [] for rows persisted before reason tracking', () => {
|
||||
expect(parsePolicyEvaluation(JSON.stringify(base))).toMatchObject({ violated: true, reasons: [] });
|
||||
});
|
||||
|
||||
it('drops reason values outside the known set', () => {
|
||||
const tampered = JSON.stringify({ ...base, reasons: ['kev', 'banana', 'fixable'] });
|
||||
expect(parsePolicyEvaluation(tampered)!.reasons).toEqual(['kev', 'fixable']);
|
||||
});
|
||||
|
||||
it('returns null for null or malformed input', () => {
|
||||
expect(parsePolicyEvaluation(null)).toBeNull();
|
||||
expect(parsePolicyEvaluation('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,7 @@ function summaryWith(violations: Array<{
|
||||
maxSeverity: string;
|
||||
severity: string;
|
||||
scanId: number;
|
||||
reasons: Array<'severity' | 'kev' | 'fixable'>;
|
||||
}>) {
|
||||
return {
|
||||
scanned: violations.length,
|
||||
@@ -142,8 +143,9 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
policyId: 1,
|
||||
policyName: 'prod-high-gate',
|
||||
maxSeverity: 'HIGH',
|
||||
severity: 'CRITICAL',
|
||||
severity: 'MEDIUM',
|
||||
scanId: 42,
|
||||
reasons: ['kev'],
|
||||
},
|
||||
{
|
||||
imageRef: 'redis:6',
|
||||
@@ -152,6 +154,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
maxSeverity: 'HIGH',
|
||||
severity: 'HIGH',
|
||||
scanId: 43,
|
||||
reasons: ['severity'],
|
||||
},
|
||||
]));
|
||||
|
||||
@@ -160,11 +163,15 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
|
||||
expect(warningCalls).toHaveLength(2);
|
||||
// The first violation matched KEV, so the alert names the known-exploited
|
||||
// input and never the severity ceiling it did not enforce.
|
||||
expect(warningCalls[0][2]).toContain('prod-high-gate');
|
||||
expect(warningCalls[0][2]).toContain('nginx:1.14');
|
||||
expect(warningCalls[0][2]).toContain('CRITICAL');
|
||||
expect(warningCalls[0][2]).toContain('HIGH');
|
||||
expect(warningCalls[0][2]).toContain('known-exploited CVE (KEV)');
|
||||
expect(warningCalls[0][2]).not.toContain('HIGH');
|
||||
// The second matched the severity threshold.
|
||||
expect(warningCalls[1][2]).toContain('redis:6');
|
||||
expect(warningCalls[1][2]).toContain('severity threshold');
|
||||
});
|
||||
|
||||
it('does not dispatch any policy alert when no violations occur', async () => {
|
||||
@@ -194,6 +201,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
maxSeverity: 'CRITICAL',
|
||||
severity: 'CRITICAL',
|
||||
scanId: 44,
|
||||
reasons: ['severity'],
|
||||
},
|
||||
]));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user