diff --git a/HISTORY.md b/HISTORY.md index 896c7b6..d8c6cfd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7286,3 +7286,37 @@ FAIL modal > тло стає inert, поки вікно відкрите а на `display: none` він і не був потрібен. Причина розписана в пункті 10. - Попередження збірки про чанк >500 kB — було до цієї роботи, до переліку не входить, і код-спліт мапи це окрема задача. + +--- + +## 2026-08-28 — Дві шкали серйозності: тригер відповідності фільтрував навпаки + +Знайдено під час першого наскрізного налаштування ескалацій: API відмовив +створити тригер із `min_severity: critical` — «невідома серйозність». Відмова +виявилась правильною, а за нею стояла тиха вада. + +**Що було.** Відповідність оперує `critical/high/medium/low`, алерти — +`info/warning/average/high/disaster`. Спільне слово рівно одне: `high`. +Подія відповідності несла свою шкалу, а поріг тригера порівнювався +лінійкою алертів (`alerting/events.go:351`), де `SeverityRank("critical")` +повертає **нуль**. Тобто тригер із порогом «warning» пропускав середні +порушення й **відкидав найважчі** — саме ті шість критичних SNMP-community, +заради яких механізм і вмикають. Мовчки: правило створене, увімкнене, +прогін іде, порушень 18, алертів нуль. + +**Що зроблено.** + +* `store.ComplianceSeverityToAlert` — переклад на шкалу алертів + (`critical→disaster`, `high→high`, `medium→average`, `low→warning`). +* `httpapi/compliance.go` віддає подію вже в шкалі алертів: далі її читають + маршрути, пороги каналів і драбини ескалації — усе, що знає одну шкалу. +* `store.NormalizeEventSeverity` — умова тригера приймає **обидві** шкали. + Людина щойно дивилась на сторінку відповідності, де написано «critical»; + вимагати подумки перекласти це в «disaster» — роздавати відмови за власну + незручність. +* Тести (`alerts_events_severity_test.go`) тримають той бік, що ламався: + найважче не може не пройти найлегший поріг, і переклад не переставляє + порядок. + +**Чого тести НЕ покривають:** саму подорож події від прогону відповідності +до каналу — це перевірено вручну на стенді, не в CI. diff --git a/server/internal/alerting/events.go b/server/internal/alerting/events.go index 7e3109c..ab5ea19 100644 --- a/server/internal/alerting/events.go +++ b/server/internal/alerting/events.go @@ -348,7 +348,13 @@ func matchesComplianceRule(cond store.Condition, ev ComplianceEvent) bool { return false } } - if cond.MinSeverity != "" && store.SeverityRank(ev.Severity) < store.SeverityRank(cond.MinSeverity) { + // Обидві сторони зводяться до шкали алертів: подія може прийти зі + // шкалою відповідності (critical/medium), і поріг людина теж могла + // написати нею. Без зведення «critical» отримував ранг нуль і + // відкидався порогом «warning» — тобто фільтр працював навпаки. + if cond.MinSeverity != "" && + store.SeverityRank(store.NormalizeEventSeverity(ev.Severity)) < + store.SeverityRank(store.NormalizeEventSeverity(cond.MinSeverity)) { return false } return true diff --git a/server/internal/httpapi/compliance.go b/server/internal/httpapi/compliance.go index 298bd1e..837e735 100644 --- a/server/internal/httpapi/compliance.go +++ b/server/internal/httpapi/compliance.go @@ -152,7 +152,12 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p * evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings)) for _, f := range stat.Findings { evs = append(evs, alerting.ComplianceEvent{ - RuleID: f.RuleID, RuleName: f.RuleName, Severity: f.Severity, + RuleID: f.RuleID, RuleName: f.RuleName, + // Алерт народжується зі шкалою АЛЕРТІВ, а не + // відповідності: далі його читають маршрути, пороги + // каналів і драбини ескалації — усе, що знає лише + // info/warning/average/high/disaster. + Severity: store.ComplianceSeverityToAlert(f.Severity), DeviceID: f.DeviceID, Passed: f.Passed, Line: f.Line, LineNumber: f.LineNumber, }) diff --git a/server/internal/store/alerts_events.go b/server/internal/store/alerts_events.go index 6a80ead..bd2527f 100644 --- a/server/internal/store/alerts_events.go +++ b/server/internal/store/alerts_events.go @@ -104,8 +104,16 @@ func ValidateRuleCondition(source string, raw []byte) error { if c.Event != "" && c.Event != "violation" { return fmt.Errorf("%w: для відповідності є лише подія violation", ErrInvalid) } - if c.MinSeverity != "" && !validSeverity[c.MinSeverity] { - return fmt.Errorf("%w: невідома серйозність %q", ErrInvalid, c.MinSeverity) + // Приймаємо обидві шкали. Людина щойно дивилась на сторінку + // відповідності, де написано «critical», і саме це слово вона + // сюди й напише; вимагати подумки перекласти його в «disaster» + // означає роздавати відмови за власну незручність. + if c.MinSeverity != "" && + !validSeverity[c.MinSeverity] && !validComplianceSeverity[c.MinSeverity] { + return fmt.Errorf( + "%w: невідома серйозність %q — буває critical, high, medium (як у правилі "+ + "відповідності) або info, warning, average, high, disaster (як в алерті)", + ErrInvalid, c.MinSeverity) } case "trap": return validateTrapCondition(c) @@ -193,6 +201,47 @@ var validSeverity = map[string]bool{ "info": true, "warning": true, "average": true, "high": true, "disaster": true, } +// Шкала правил ВІДПОВІДНОСТІ. Інша, і це не недогляд: відповідність +// говорить мовою аудиту, алерти — мовою чергового. Спільне слово одне — +// «high». +var validComplianceSeverity = map[string]bool{ + "critical": true, "high": true, "medium": true, "low": true, +} + +// ComplianceSeverityToAlert переводить серйозність правила відповідності +// у шкалу алертів. +// +// Без цього переведення виходила тиха й ЗВОРОТНА помилка. Подія несла +// серйозність відповідності, а порівнювалась через SeverityRank, який +// знає лише шкалу алертів і на «critical» повертає нуль. Тобто тригер +// із порогом «warning» пропускав середні порушення й ВІДКИДАВ +// найважчі — рівно ті, через які цей механізм і вмикають. +// +// Напрямок очевидний: найгірше в одній шкалі стає найгіршим у другій. +func ComplianceSeverityToAlert(s string) string { + switch s { + case "critical": + return "disaster" + case "high": + return "high" + case "medium": + return "average" + case "low": + return "warning" + } + // Невідоме не піднімаємо й не занижуємо мовчки: «warning» означає + // «покажи, але не буди», і для незнайомого слова це найчесніше. + return "warning" +} + +// NormalizeEventSeverity зводить будь-яку з двох шкал до шкали алертів. +func NormalizeEventSeverity(s string) string { + if validSeverity[s] { + return s + } + return ComplianceSeverityToAlert(s) +} + // SeverityRank — порядок серйозності, той самий, що в alr.severity. func SeverityRank(s string) int { switch s { diff --git a/server/internal/store/alerts_events_severity_test.go b/server/internal/store/alerts_events_severity_test.go new file mode 100644 index 0000000..d8bdcf5 --- /dev/null +++ b/server/internal/store/alerts_events_severity_test.go @@ -0,0 +1,53 @@ +package store + +import "testing" + +// Дві шкали в одному продукті — і поріг тригера порівнював їх однією +// лінійкою. Тест тримає саме той бік, який ламався: НАЙВАЖЧЕ порушення +// відповідності не має відкидатись порогом, який пропускає легші. +// +// До виправлення SeverityRank("critical") давав нуль, тож правило з +// порогом «warning» пропускало high і мовчки викидало critical — тобто +// фільтр працював навпаки, і саме на тих шести знахідках, через які +// механізм і вмикають. +func TestComplianceSeverityDoesNotInvertThreshold(t *testing.T) { + pass := func(ev, min string) bool { + return SeverityRank(NormalizeEventSeverity(ev)) >= + SeverityRank(NormalizeEventSeverity(min)) + } + + for _, c := range []struct { + ev, min string + want bool + why string + }{ + {"critical", "warning", true, "найважче не може не пройти найлегший поріг"}, + {"critical", "high", true, "критичне важче за high"}, + {"high", "warning", true, ""}, + {"medium", "warning", true, "medium — це average, воно вище за warning"}, + {"medium", "high", false, "середнє не має будити за порогом high"}, + {"critical", "disaster", true, "critical і є disaster"}, + {"high", "disaster", false, ""}, + // Поріг теж міг бути написаний шкалою відповідності. + {"disaster", "critical", true, ""}, + {"average", "medium", true, ""}, + } { + if got := pass(c.ev, c.min); got != c.want { + t.Errorf("подія %q проти порогу %q: маємо %v, очікували %v %s", + c.ev, c.min, got, c.want, c.why) + } + } +} + +// Порядок має зберігатись: переклад не може переставити місцями те, що +// в початковій шкалі стояло по-різному. +func TestComplianceSeverityKeepsOrder(t *testing.T) { + order := []string{"low", "medium", "high", "critical"} + for i := 1; i < len(order); i++ { + lo := SeverityRank(ComplianceSeverityToAlert(order[i-1])) + hi := SeverityRank(ComplianceSeverityToAlert(order[i])) + if lo >= hi { + t.Fatalf("%s (%d) не нижче за %s (%d)", order[i-1], lo, order[i], hi) + } + } +}