Дві шкали серйозності: тригер відповідності фільтрував навпаки
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m5s
CI / server (push) Successful in 2m17s
CI / agent (push) Successful in 2m53s

Подія відповідності несла шкалу critical/high/medium, а поріг тригера
порівнювався шкалою алертів, де SeverityRank("critical") = 0. Тригер із
порогом «warning» пропускав середнє й відкидав найважче — тихо.

Подія тепер народжується в шкалі алертів; умова тригера приймає обидві.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-28 14:10:49 +03:00
parent e0fdfcde06
commit 3b9217de3c
5 changed files with 151 additions and 4 deletions

View file

@ -7286,3 +7286,37 @@ FAIL modal > тло стає inert, поки вікно відкрите
а на `display: none` він і не був потрібен. Причина розписана в пункті 10. а на `display: none` він і не був потрібен. Причина розписана в пункті 10.
- Попередження збірки про чанк >500 kB — було до цієї роботи, до переліку не - Попередження збірки про чанк >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.

View file

@ -348,7 +348,13 @@ func matchesComplianceRule(cond store.Condition, ev ComplianceEvent) bool {
return false 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 false
} }
return true return true

View file

@ -152,7 +152,12 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p *
evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings)) evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings))
for _, f := range stat.Findings { for _, f := range stat.Findings {
evs = append(evs, alerting.ComplianceEvent{ 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, DeviceID: f.DeviceID, Passed: f.Passed,
Line: f.Line, LineNumber: f.LineNumber, Line: f.Line, LineNumber: f.LineNumber,
}) })

View file

@ -104,8 +104,16 @@ func ValidateRuleCondition(source string, raw []byte) error {
if c.Event != "" && c.Event != "violation" { if c.Event != "" && c.Event != "violation" {
return fmt.Errorf("%w: для відповідності є лише подія violation", ErrInvalid) 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": case "trap":
return validateTrapCondition(c) return validateTrapCondition(c)
@ -193,6 +201,47 @@ var validSeverity = map[string]bool{
"info": true, "warning": true, "average": true, "high": true, "disaster": true, "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. // SeverityRank — порядок серйозності, той самий, що в alr.severity.
func SeverityRank(s string) int { func SeverityRank(s string) int {
switch s { switch s {

View file

@ -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)
}
}
}