mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat: stack glob patterns and route severity levels (#1651)
* feat: add stack glob patterns and route severity levels Operators can filter notification routes and mute rules with anchored * globs, and routes can target info, warning, or error. Matching is fail-closed for unsafe stored patterns; write paths keep partial-PUT semantics and ReDoS caps. * test: split mute and routing chip tests to avoid dialog race * fix: move stack pattern client validator out of PatternChips * fix: bound stack glob matching and atomic pattern chip saves
This commit is contained in:
@@ -89,6 +89,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: `http://apprise.local/notify/${keySecret}`,
|
||||
config: '{}',
|
||||
@@ -173,6 +174,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/token',
|
||||
config: null,
|
||||
@@ -187,6 +189,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/route-key',
|
||||
config: '{}',
|
||||
|
||||
@@ -69,6 +69,7 @@ function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'apprise' as const,
|
||||
channel_url: KEYED,
|
||||
config: '{}',
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('Apprise channel helpers', () => {
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/path-secret',
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
|
||||
@@ -73,6 +73,36 @@ describe('notificationMatchers', () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('matches stack globs with OR across patterns', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['prod-*', 'my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(true);
|
||||
expect(matchesNotificationFilters({ ...baseCtx, stackName: 'prod-web' }, {
|
||||
node_id: null,
|
||||
stack_patterns: ['prod-*'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(true);
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['prod-*'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on invalid stored stack patterns', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['****'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('detects when stack labels are needed', () => {
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true);
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Additive `levels` column on notification_routes.
|
||||
* Exercises production DatabaseService startup against a pre-levels schema.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
const existing = holder.instance;
|
||||
if (existing) {
|
||||
try {
|
||||
existing.getDb().close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
holder.instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('notification route levels column migration', () => {
|
||||
let scratchDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
resetDatabaseSingleton();
|
||||
if (scratchDir) {
|
||||
try {
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
scratchDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds levels via DatabaseService startup; legacy rows load as null; reopen is idempotent', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-route-levels-mig-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE notification_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
channel_url TEXT NOT NULL,
|
||||
stack_patterns TEXT NOT NULL DEFAULT '[]',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_routes
|
||||
(name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at)
|
||||
VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
|
||||
expect(routeCols.filter((c) => c.name === 'levels')).toHaveLength(1);
|
||||
|
||||
const route = db.getNotificationRoutes().find((r) => r.name === 'Legacy');
|
||||
expect(route).toBeDefined();
|
||||
expect(route!.levels).toBeNull();
|
||||
|
||||
resetDatabaseSingleton();
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
const db2 = DatabaseService.getInstance();
|
||||
const routeCols2 = db2.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
|
||||
expect(routeCols2.filter((c) => c.name === 'levels')).toHaveLength(1);
|
||||
expect(db2.getNotificationRoutes().find((r) => r.name === 'Legacy')!.levels).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -824,3 +824,85 @@ describe('GET /api/notifications - history', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to clear notifications:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification routes - glob patterns and levels', () => {
|
||||
it('POST omits stack_patterns and levels to defaults', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'defaults',
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/abc',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.stack_patterns).toEqual([]);
|
||||
expect(res.body.levels).toBeNull();
|
||||
DatabaseService.getInstance().deleteNotificationRoute(res.body.id);
|
||||
});
|
||||
|
||||
it('POST rejects null stack_patterns and ReDoS patterns', async () => {
|
||||
const nullRes = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'bad-null',
|
||||
stack_patterns: null,
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/abc',
|
||||
});
|
||||
expect(nullRes.status).toBe(400);
|
||||
|
||||
const redos = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'bad-redos',
|
||||
stack_patterns: ['****'],
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/abc',
|
||||
});
|
||||
expect(redos.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST/GET/PUT levels round-trip; invalid levels 400', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'level-route',
|
||||
stack_patterns: ['prod-*'],
|
||||
levels: ['error'],
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/abc',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.levels).toEqual(['error']);
|
||||
expect(created.body.stack_patterns).toEqual(['prod-*']);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const bad = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ levels: ['critical'] });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const partial = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'level-route-renamed' });
|
||||
expect(partial.status).toBe(200);
|
||||
expect(partial.body.levels).toEqual(['error']);
|
||||
expect(partial.body.stack_patterns).toEqual(['prod-*']);
|
||||
|
||||
const cleared = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ levels: null, stack_patterns: [] });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(cleared.body.levels).toBeNull();
|
||||
expect(cleared.body.stack_patterns).toEqual([]);
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationRoute(id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ function makeRoute(overrides: Record<string, unknown> = {}) {
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null as number[] | null,
|
||||
categories: null as string[] | null,
|
||||
levels: null as ('info' | 'warning' | 'error')[] | null,
|
||||
channel_type: 'discord' as const,
|
||||
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
||||
priority: 0,
|
||||
@@ -370,6 +371,53 @@ describe('NotificationService - routing logic', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes by severity when levels is set; mismatches fall back to agents', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: [], levels: ['error'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('info', 'monitor_alert', 'Info only', { stackName: 'my-app' });
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: [], levels: ['error'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Error route', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed on invalid stored route patterns without blocking agent fallback', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: ['****'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Bad pattern', { stackName: 'my-app' });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fires a category-only route when category matches', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }),
|
||||
|
||||
@@ -173,4 +173,126 @@ describe('Notification suppression - CRUD', () => {
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST omits stack_patterns to []; rejects malformed and ReDoS patterns', async () => {
|
||||
const omitted = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Mute omit',
|
||||
applies_to: 'both',
|
||||
});
|
||||
expect(omitted.status).toBe(201);
|
||||
expect(omitted.body.stack_patterns).toEqual([]);
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(omitted.body.id);
|
||||
|
||||
const bad = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, name: 'bad', stack_patterns: null });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const redos = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, name: 'redos', stack_patterns: ['****'] });
|
||||
expect(redos.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT enabled-only preserves patterns; explicit [] clears', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const partial = await request(app)
|
||||
.put(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ enabled: false });
|
||||
expect(partial.status).toBe(200);
|
||||
expect(partial.body.enabled).toBe(false);
|
||||
expect(partial.body.stack_patterns).toEqual(['staging']);
|
||||
expect(partial.body.levels).toEqual(['warning']);
|
||||
|
||||
const cleared = await request(app)
|
||||
.put(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ stack_patterns: [] });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(cleared.body.stack_patterns).toEqual([]);
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
});
|
||||
|
||||
it('replica requires and validates stack_patterns', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
const missing = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
|
||||
const redos = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
stack_patterns: ['****'],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
});
|
||||
expect(redos.status).toBe(400);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)).toBeUndefined();
|
||||
|
||||
const ok = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
stack_patterns: ['prod-*'],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(9001);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ function makeRoute() {
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'discord' as const,
|
||||
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
||||
priority: 0,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stackPatternMatches,
|
||||
validateStackPatternForRedos,
|
||||
parseStackPatternsInput,
|
||||
cleanStackPatterns,
|
||||
} from '../helpers/stackPattern';
|
||||
|
||||
describe('stackPattern', () => {
|
||||
it('matches exact names without wildcards', () => {
|
||||
expect(stackPatternMatches('prod-api', 'prod-api')).toBe(true);
|
||||
expect(stackPatternMatches('Prod-api', 'prod-api')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches anchored case-sensitive globs', () => {
|
||||
expect(stackPatternMatches('prod-api', 'prod-*')).toBe(true);
|
||||
expect(stackPatternMatches('staging-api', 'prod-*')).toBe(false);
|
||||
expect(stackPatternMatches('prod-api-extra', 'prod-*')).toBe(true);
|
||||
expect(stackPatternMatches('xprod-api', 'prod-*')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats ? and regex metacharacters as literal', () => {
|
||||
expect(stackPatternMatches('a?b', 'a?b')).toBe(true);
|
||||
expect(stackPatternMatches('ab', 'a?b')).toBe(false);
|
||||
expect(stackPatternMatches('a.b', 'a.b')).toBe(true);
|
||||
expect(stackPatternMatches('axb', 'a.b')).toBe(false);
|
||||
expect(stackPatternMatches('a+b', 'a+b')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed on write-cap rejected patterns without throwing', () => {
|
||||
expect(() => stackPatternMatches('anything', '****')).not.toThrow();
|
||||
expect(stackPatternMatches('anything', '****')).toBe(false);
|
||||
expect(stackPatternMatches('x', 'a'.repeat(201))).toBe(false);
|
||||
expect(stackPatternMatches('x', `${'a*'.repeat(9)}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches accepted separated-star patterns without RegExp backtracking', () => {
|
||||
const pattern = '*a*a*a*a*a*a*a*a';
|
||||
expect(validateStackPatternForRedos(pattern)).toBeNull();
|
||||
expect(stackPatternMatches('aaaaaaaa', pattern)).toBe(true);
|
||||
const nonMatch = `${'a'.repeat(80)}b`;
|
||||
const started = performance.now();
|
||||
expect(stackPatternMatches(nonMatch, pattern)).toBe(false);
|
||||
expect(performance.now() - started).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('OR semantics are call-site: any pattern may match', () => {
|
||||
const patterns = ['staging-*', 'prod-api'];
|
||||
expect(patterns.some((p) => stackPatternMatches('prod-api', p))).toBe(true);
|
||||
expect(patterns.some((p) => stackPatternMatches('staging-web', p))).toBe(true);
|
||||
expect(patterns.some((p) => stackPatternMatches('dev-web', p))).toBe(false);
|
||||
});
|
||||
|
||||
it('validateStackPatternForRedos rejects unsafe inputs', () => {
|
||||
expect(validateStackPatternForRedos('ok-*')).toBeNull();
|
||||
expect(validateStackPatternForRedos('****')).toMatch(/consecutive/);
|
||||
expect(validateStackPatternForRedos('a'.repeat(201))).toMatch(/too long/);
|
||||
expect(validateStackPatternForRedos('*a*a*a*a*a*a*a*a')).toBeNull();
|
||||
});
|
||||
|
||||
it('parseStackPatternsInput rejects non-arrays and non-strings', () => {
|
||||
expect(parseStackPatternsInput(null).ok).toBe(false);
|
||||
expect(parseStackPatternsInput('prod-*').ok).toBe(false);
|
||||
expect(parseStackPatternsInput([1]).ok).toBe(false);
|
||||
expect(parseStackPatternsInput(['prod-*', '****']).ok).toBe(false);
|
||||
expect(parseStackPatternsInput([' prod-* ', 'prod-*'])).toEqual({
|
||||
ok: true,
|
||||
patterns: ['prod-*'],
|
||||
});
|
||||
});
|
||||
|
||||
it('cleanStackPatterns trims, drops blanks, dedupes', () => {
|
||||
expect(cleanStackPatterns([' a ', '', 'a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Agent, NotificationRoute } from '../services/DatabaseService';
|
||||
|
||||
export { cleanStackPatterns } from './stackPattern';
|
||||
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
|
||||
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
|
||||
|
||||
@@ -49,9 +51,6 @@ function notifyKeyFromPath(path: string): string | null {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export const cleanStackPatterns = (patterns: string[]): string[] =>
|
||||
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
|
||||
|
||||
export function validateHttpsUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'string' || !value.startsWith('https://')) return 'must be a valid HTTPS URL';
|
||||
try { new URL(value); } catch { return 'is not a valid URL'; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NotificationCategory } from '../services/NotificationService';
|
||||
import { stackPatternMatches } from './stackPattern';
|
||||
|
||||
export type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
export type NotificationAppliesTo = 'bell' | 'external' | 'both';
|
||||
@@ -27,7 +28,8 @@ export function matchesNotificationFilters(
|
||||
if (rule.node_id != null && rule.node_id !== ctx.localNodeId) return false;
|
||||
if (
|
||||
rule.stack_patterns.length > 0
|
||||
&& (ctx.stackName === undefined || !rule.stack_patterns.includes(ctx.stackName))
|
||||
&& (ctx.stackName === undefined
|
||||
|| !rule.stack_patterns.some((pattern) => stackPatternMatches(ctx.stackName!, pattern)))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Canonical stack-name glob matching and write-boundary validation.
|
||||
*
|
||||
* Glob only: `*` is the sole wildcard; `?` and regex metacharacters are literal.
|
||||
* Matching is case-sensitive and anchored. Matching never uses RegExp so
|
||||
* accepted patterns cannot trigger catastrophic backtracking.
|
||||
*
|
||||
* Write-time caps still reject extreme patterns (length, star count, runs of
|
||||
* consecutive stars). Invalid patterns fail closed at match time (no match).
|
||||
*/
|
||||
|
||||
export function validateStackPatternForRedos(pattern: string): string | null {
|
||||
if (pattern.length > 200) return 'stack_pattern is too long';
|
||||
const stars = (pattern.match(/\*/g) ?? []).length;
|
||||
if (stars > 8) return 'stack_pattern has too many wildcards (max 8)';
|
||||
if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function cleanStackPatterns(patterns: string[]): string[] {
|
||||
return [...new Set(patterns.map((p) => p.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export type ParseStackPatternsResult =
|
||||
| { ok: true; patterns: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Validate a present `stack_patterns` value. Callers must not invoke this for omitted keys. */
|
||||
export function parseStackPatternsInput(raw: unknown): ParseStackPatternsResult {
|
||||
if (!Array.isArray(raw)) {
|
||||
return { ok: false, error: 'stack_patterns must be an array of strings' };
|
||||
}
|
||||
if (raw.some((p) => typeof p !== 'string')) {
|
||||
return { ok: false, error: 'stack_patterns must be an array of strings' };
|
||||
}
|
||||
const patterns = cleanStackPatterns(raw as string[]);
|
||||
for (const pattern of patterns) {
|
||||
const err = validateStackPatternForRedos(pattern);
|
||||
if (err) return { ok: false, error: err };
|
||||
}
|
||||
return { ok: true, patterns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded glob match: work is linear in name length times star count (no RegExp).
|
||||
* `*` matches any substring including empty; all other characters are literal.
|
||||
*/
|
||||
function globMatchBounded(name: string, pattern: string): boolean {
|
||||
let ni = 0;
|
||||
let pi = 0;
|
||||
let starP = -1;
|
||||
let starN = -1;
|
||||
const nLen = name.length;
|
||||
const pLen = pattern.length;
|
||||
|
||||
while (ni < nLen) {
|
||||
if (pi < pLen && pattern[pi] !== '*' && pattern[pi] === name[ni]) {
|
||||
ni += 1;
|
||||
pi += 1;
|
||||
continue;
|
||||
}
|
||||
if (pi < pLen && pattern[pi] === '*') {
|
||||
starP = pi;
|
||||
starN = ni;
|
||||
pi += 1;
|
||||
continue;
|
||||
}
|
||||
if (starP !== -1) {
|
||||
pi = starP + 1;
|
||||
starN += 1;
|
||||
ni = starN;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
while (pi < pLen && pattern[pi] === '*') pi += 1;
|
||||
return pi === pLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `name` matches the stack glob. Patterns rejected by write-time
|
||||
* caps return false without throwing so dispatch and policy evaluation stay safe.
|
||||
*/
|
||||
export function stackPatternMatches(name: string, pattern: string): boolean {
|
||||
if (validateStackPatternForRedos(pattern) !== null) return false;
|
||||
return globMatchBounded(name, pattern);
|
||||
}
|
||||
@@ -29,6 +29,9 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentati
|
||||
import { getLatestVersion, getLatestRelease } from '../utils/version-check';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { validateStackPatternForRedos } from '../helpers/stackPattern';
|
||||
|
||||
export { validateStackPatternForRedos } from '../helpers/stackPattern';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
import { parseRequestedTargetVersion, pickCompareTarget } from '../utils/targetVersion';
|
||||
@@ -170,21 +173,8 @@ function validateScanPolicyRow(row: unknown): string | null {
|
||||
|
||||
/**
|
||||
* Reject `stack_pattern` inputs that would compile to a backtracking-prone
|
||||
* regex. The matcher in `getMatchingPolicy` substitutes `*` with `.*`, so a
|
||||
* pattern like `***...` becomes a chain of adjacent `.*` runs that exhibit
|
||||
* catastrophic backtracking on long inputs.
|
||||
*
|
||||
* Caps mirror the limit in routes/security.ts so a control creating a policy
|
||||
* sees the same error as a replica receiving one. Length is gated at 200 by
|
||||
* the surrounding row validator.
|
||||
* regex. Implementation lives in helpers/stackPattern.ts (re-exported above).
|
||||
*/
|
||||
export function validateStackPatternForRedos(pattern: string): string | null {
|
||||
if (pattern.length > 200) return 'stack_pattern is too long';
|
||||
const stars = (pattern.match(/\*/g) ?? []).length;
|
||||
if (stars > 8) return 'stack_pattern has too many wildcards (max 8)';
|
||||
if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards';
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCveSuppressionRow(row: unknown): string | null {
|
||||
if (!row || typeof row !== 'object') return 'row must be an object';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
cleanStackPatterns,
|
||||
maskWebhookUrl,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
resolvePreservedAppriseConfig,
|
||||
storedAppriseToWriteConfig,
|
||||
} from '../helpers/notificationChannels';
|
||||
import { parseStackPatternsInput } from '../helpers/stackPattern';
|
||||
import {
|
||||
deleteSuppressionRuleFromFleet,
|
||||
syncSuppressionRuleToFleet,
|
||||
@@ -109,6 +109,28 @@ function validateExpiresAt(expires_at: unknown, res: Response): number | null |
|
||||
return expires_at;
|
||||
}
|
||||
|
||||
function normalizeStoredLevels(levels: unknown): ('info' | 'warning' | 'error')[] | null {
|
||||
if (!Array.isArray(levels) || levels.length === 0) return null;
|
||||
return levels as ('info' | 'warning' | 'error')[];
|
||||
}
|
||||
|
||||
/** Resolve stack_patterns with presence semantics. Returns false after sending 400. */
|
||||
function resolveStackPatternsField(
|
||||
stack_patterns: unknown,
|
||||
opts: { isCreate: boolean },
|
||||
res: Response,
|
||||
): string[] | undefined | false {
|
||||
if (stack_patterns === undefined) {
|
||||
return opts.isCreate ? [] : undefined;
|
||||
}
|
||||
const parsed = parseStackPatternsInput(stack_patterns);
|
||||
if (!parsed.ok) {
|
||||
res.status(400).json({ error: parsed.error });
|
||||
return false;
|
||||
}
|
||||
return parsed.patterns;
|
||||
}
|
||||
|
||||
function parseSuppressionRuleBody(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -144,16 +166,8 @@ function parseSuppressionRuleBody(
|
||||
: undefined;
|
||||
if (nodeIdResult === false) return null;
|
||||
|
||||
let cleanedPatterns: string[] | undefined;
|
||||
if (stack_patterns !== undefined) {
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return null;
|
||||
}
|
||||
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
||||
} else if (isCreate) {
|
||||
cleanedPatterns = [];
|
||||
}
|
||||
const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate }, res);
|
||||
if (cleanedPatterns === false) return null;
|
||||
|
||||
if (!validateLabelIds(label_ids, res)) return null;
|
||||
if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null;
|
||||
@@ -180,7 +194,7 @@ function parseSuppressionRuleBody(
|
||||
stack_patterns: cleanedPatterns ?? [],
|
||||
label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null,
|
||||
categories: Array.isArray(categories) && categories.length > 0 ? categories : null,
|
||||
levels: Array.isArray(levels) && levels.length > 0 ? levels : null,
|
||||
levels: normalizeStoredLevels(levels),
|
||||
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
|
||||
enabled: enabled !== false,
|
||||
expires_at: expiresAtResult ?? null,
|
||||
@@ -282,7 +296,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
@@ -294,13 +308,11 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
}
|
||||
const nodeIdResult = validateNodeId(rawNodeId, res);
|
||||
if (nodeIdResult === false) return;
|
||||
const cleanedPatterns = Array.isArray(stack_patterns) ? cleanStackPatterns(stack_patterns) : [];
|
||||
if (Array.isArray(stack_patterns) && stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate: true }, res);
|
||||
if (cleanedPatterns === false) return;
|
||||
if (!validateLabelIds(label_ids, res)) return;
|
||||
if (!validateCategories(categories, res)) return;
|
||||
if (!validateLevels(levels, res)) return;
|
||||
if (!(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) {
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
@@ -316,9 +328,10 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
const route = DatabaseService.getInstance().createNotificationRoute({
|
||||
name: name.trim(),
|
||||
node_id: nodeIdResult,
|
||||
stack_patterns: cleanedPatterns,
|
||||
stack_patterns: cleanedPatterns ?? [],
|
||||
label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null,
|
||||
categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null,
|
||||
levels: normalizeStoredLevels(levels),
|
||||
channel_type,
|
||||
channel_url: channel_url.trim(),
|
||||
config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null,
|
||||
@@ -328,7 +341,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
updated_at: now,
|
||||
});
|
||||
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog((cleanedPatterns ?? []).join(', '))}], channel=${channel_type}`);
|
||||
res.status(201).json(serializePublicNotificationRoute(route));
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification route:', error);
|
||||
@@ -345,7 +358,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
@@ -361,16 +374,11 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
if (result === false) return;
|
||||
validatedNodeId = result;
|
||||
}
|
||||
let cleanedPatterns: string[] | undefined;
|
||||
if (stack_patterns !== undefined) {
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
||||
}
|
||||
const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate: false }, res);
|
||||
if (cleanedPatterns === false) return;
|
||||
if (!validateLabelIds(label_ids, res)) return;
|
||||
if (!validateCategories(categories, res)) return;
|
||||
if ('levels' in req.body && !validateLevels(levels, res)) return;
|
||||
if (channel_type !== undefined && !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) {
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
@@ -413,6 +421,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
||||
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
|
||||
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
|
||||
if ('levels' in req.body) updates.levels = normalizeStoredLevels(levels);
|
||||
if (channel_type !== undefined) updates.channel_type = channel_type;
|
||||
if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl;
|
||||
if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig);
|
||||
@@ -490,7 +499,19 @@ notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, re
|
||||
res.status(400).json({ error: 'Invalid applies_to on rule' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule);
|
||||
if (!('stack_patterns' in rule)) {
|
||||
res.status(400).json({ error: 'stack_patterns is required on replica rule' });
|
||||
return;
|
||||
}
|
||||
const patterns = parseStackPatternsInput(rule.stack_patterns);
|
||||
if (!patterns.ok) {
|
||||
res.status(400).json({ error: patterns.error });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||
...rule,
|
||||
stack_patterns: patterns.patterns,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to apply suppression rule replica:', error);
|
||||
@@ -582,11 +603,9 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
|
||||
|
||||
let cleanedPatterns: string[] | undefined;
|
||||
if (stack_patterns !== undefined) {
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
||||
const resolved = resolveStackPatternsField(stack_patterns, { isCreate: false }, res);
|
||||
if (resolved === false) return;
|
||||
cleanedPatterns = resolved;
|
||||
}
|
||||
|
||||
if (!validateLabelIds(label_ids, res)) return;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
||||
import { validateStackPatternForRedos } from './fleet';
|
||||
import { validateStackPatternForRedos } from '../helpers/stackPattern';
|
||||
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { isNoOpBlockingPolicy } from '../utils/policy-risk';
|
||||
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { AuditStatsInput } from './AuditAnomalyService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
|
||||
import { HIGH_EPSS_THRESHOLD } from './securityPosture';
|
||||
import type { BackendScheduledAction } from './scheduledActionRegistry';
|
||||
import { stackPatternMatches } from '../helpers/stackPattern';
|
||||
|
||||
function isPilotMode(): boolean {
|
||||
return process.env.SENCHO_MODE === 'pilot';
|
||||
@@ -685,6 +686,7 @@ export interface NotificationRoute {
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
levels: ('info' | 'warning' | 'error')[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_url: string;
|
||||
config?: string | null;
|
||||
@@ -983,6 +985,7 @@ export class DatabaseService {
|
||||
this.migrateNotificationRoutesNodeId();
|
||||
this.migrateNotificationRoutesMatchers();
|
||||
this.migrateNotificationChannelConfig();
|
||||
this.migrateNotificationRouteLevels();
|
||||
this.migrateNotificationSuppressionRules();
|
||||
this.migrateNotificationHistoryContext();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
@@ -2078,6 +2081,10 @@ export class DatabaseService {
|
||||
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationRouteLevels(): void {
|
||||
this.tryAddColumn('notification_routes', 'levels', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationSuppressionRules(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
|
||||
@@ -2568,6 +2575,7 @@ export class DatabaseService {
|
||||
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
|
||||
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
|
||||
levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
|
||||
channel_type,
|
||||
channel_url: fields.url,
|
||||
config: fields.config,
|
||||
@@ -2605,13 +2613,14 @@ export class DatabaseService {
|
||||
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
||||
const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
route.name,
|
||||
route.node_id ?? null,
|
||||
JSON.stringify(route.stack_patterns),
|
||||
route.label_ids ? JSON.stringify(route.label_ids) : null,
|
||||
route.categories ? JSON.stringify(route.categories) : null,
|
||||
route.levels && route.levels.length > 0 ? JSON.stringify(route.levels) : null,
|
||||
route.channel_type,
|
||||
stored.url,
|
||||
stored.config,
|
||||
@@ -2635,6 +2644,10 @@ export class DatabaseService {
|
||||
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
|
||||
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
|
||||
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
|
||||
if ('levels' in updates) {
|
||||
fields.push('levels = ?');
|
||||
values.push(updates.levels && updates.levels.length > 0 ? JSON.stringify(updates.levels) : null);
|
||||
}
|
||||
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
|
||||
if (updates.channel_url !== undefined) {
|
||||
fields.push('channel_url = ?');
|
||||
@@ -6396,10 +6409,7 @@ export class DatabaseService {
|
||||
const matchesStack = (pattern: string | null): boolean => {
|
||||
if (!pattern) return true;
|
||||
if (!stackName) return false;
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(stackName);
|
||||
return stackPatternMatches(stackName, pattern);
|
||||
};
|
||||
const matchesIdentity = (p: ScanPolicy): boolean => {
|
||||
// Locally created policies (never replicated) apply based on node_id logic already filtered.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* cross-multiply with the fleet ack list on every render.
|
||||
*/
|
||||
import type { MisconfigAcknowledgement } from '../services/DatabaseService';
|
||||
import { stackPatternMatches } from '../helpers/stackPattern';
|
||||
|
||||
export interface MisconfigAcknowledgementDecision {
|
||||
acknowledged: boolean;
|
||||
@@ -31,8 +32,7 @@ function matchesStackPattern(pattern: string | null, stackContext: string | null
|
||||
if (!pattern) return true;
|
||||
// Stack-scoped acks against an image scan (no stack_context) cannot match.
|
||||
if (stackContext === null) return false;
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
return new RegExp(`^${escaped}$`).test(stackContext);
|
||||
return stackPatternMatches(stackContext, pattern);
|
||||
}
|
||||
|
||||
function isActive(ack: MisconfigAcknowledgement, now: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user