Netpulse_SasS/server/internal/store/ncm_compliance.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

869 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
)
// ComplianceRule — вимога до конфігурації пристрою.
//
// Відповідає на питання, яке в мережі задають постійно й перевіряють
// руками: «чи на всіх комутаторах закритий telnet», «чи скрізь наш NTP»,
// «чи не лишився десь community public». Руками це роблять раз на рік і
// знаходять половину.
type ComplianceRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Kind string `json:"kind"`
Pattern string `json:"pattern"`
Severity string `json:"severity"`
Selector Selector `json:"selector"`
Remediation string `json:"remediation,omitempty"`
Enabled bool `json:"enabled"`
// Тип конфігу, до якого правило застосовне: running, startup, files.
//
// Без нього перевірка брала просто найсвіжішу версію хоста — і на
// хості з двома типами (залізо + набір файлів сервера) перевіряла
// той, що зібрався пізніше. Помилка мовчазна: правило не падає й не
// попереджає, просто відповідає про інший документ.
ConfigType string `json:"config_type"`
// BuiltinKey — ключ довідника ncm.builtin_compliance, з якого
// зроблено цю копію; порожньо — правило написала людина.
//
// Поле віддається клієнтові навмисно, і не заради значка. Вбудоване
// правило має за спиною перевірку (ncm_compliance_builtin_test.go):
// його зразок доведено спрацював хоч на одному справжньому конфізі й
// доведено не спрацював хоч на одному іншому. Правка зразка руками
// цю гарантію знімає МОВЧКИ — правило лишається впізнаваним як
// вбудоване й починає означати щось інше. Тому предмет перевірки
// вбудованого правила замкнено (див. checkBuiltinImmutable), а
// клієнт має знати, що саме йому показувати сірим і чому.
BuiltinKey string `json:"builtin_key,omitempty"`
// Скільки хостів під правилом не пройшли перевірку — для списку.
Failed int `json:"failed"`
Passed int `json:"passed"`
}
// ComplianceResult — результат однієї перевірки на одному хості.
type ComplianceResult struct {
RuleID string `json:"rule_id"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
ConfigID string `json:"config_id,omitempty"`
Passed bool `json:"passed"`
// Рядок конфігу, який спричинив висновок, і його номер. Для правил
// «не має бути» це знахідка, для «має бути» — порожньо: показувати
// нічого, бо саме відсутність і є проблемою.
Line string `json:"line,omitempty"`
LineNumber int `json:"line_number,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
var complianceKinds = map[string]bool{
"must_contain": true, "must_not_contain": true,
"regex_match": true, "regex_absent": true,
}
// Серйозності — рівно значення ncm.rule_severity з 0006.
//
// Перевіряються тут, а не покладаються на приведення `$6::ncm.rule_severity`
// у SQL: база на невідомому значенні дасть 22P02 і 500 «внутрішня
// помилка», тобто відправить людину шукати поломку сервера замість
// друкарської помилки у власній формі.
var complianceSeverities = map[string]bool{
"info": true, "low": true, "medium": true, "high": true, "critical": true,
}
// Типи конфігу, до яких застосовне правило.
//
// Перелік закритий, і це найважливіша перевірка з усіх трьох. config_type
// у базі — звичайний text (0056), тобто «runnning» з одним зайвим `n`
// зберігається без єдиної скарги. Наслідок такої описки: жоден хост не
// має конфігу цього типу, latestConfigBody нічого не знаходить, УСІ пари
// йдуть у stat.Skipped — і правило показує «порушень немає». Рівно та
// форма тихої брехні, від якої 0056 і рятувала: впевнена відповідь про
// документ, якого ніхто не читав.
var complianceConfigTypes = map[string]bool{
"running": true, "startup": true, "files": true,
}
// validateComplianceRule — усе, що можна сказати про правило, не
// заглядаючи ні в базу, ні в конфіги.
//
// Окремою функцією навмисно: так її перевіряє звичайний тест без
// Postgres, а не лише прогін проти бази, який на машині розробника
// мовчки пропускається (NETPULSE_TEST_DSN).
func validateComplianceRule(r ComplianceRule) error {
if !complianceKinds[r.Kind] {
return fmt.Errorf("%w: невідомий вид правила %q", ErrInvalid, r.Kind)
}
if !complianceSeverities[r.Severity] {
return fmt.Errorf("%w: невідома серйозність %q", ErrInvalid, r.Severity)
}
if !complianceConfigTypes[r.ConfigType] {
return fmt.Errorf("%w: невідомий тип конфігу %q — правило не підпало б до жодного хоста "+
"й показувало б «порушень немає»", ErrInvalid, r.ConfigType)
}
if strings.TrimSpace(r.Pattern) == "" {
return fmt.Errorf("%w: правило без зразка нічого не перевіряє", ErrInvalid)
}
// Перенос рядка у зразку — зразок, який не збіжиться НІКОЛИ.
//
// ПРИЧИНА: і checkCompliance, і probeMatches ідуть конфігом
// ПОРЯДКОВО (strings.Split по "\n"), тобто жоден рядок, поданий на
// перевірку, переносу вже не містить. Зразок із переносом не має
// шансу збігтися з жодним із них.
//
// НАСЛІДОК, якби пропускали: для must_contain це вічне «порушення на
// кожному хості» — помилка гучна, її помітять того ж дня. Для
// must_not_contain і regex_absent — вічне тихе «пройдено», тобто
// вигляд бездоганної мережі. Саме друга форма й коштує дорого.
if strings.ContainsAny(r.Pattern, "\n\r") {
return fmt.Errorf("%w: зразок містить перенос рядка — перевірка йде порядково, "+
"тож такий зразок не збігся б ніколи й показував би «порушень немає»", ErrInvalid)
}
// Регулярний вираз компілюємо тут, а не під час перевірки: інакше
// про друкарську помилку людина дізнається не з форми, а з правила,
// яке мовчки нічого не знаходить.
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
if _, err := regexp.Compile(r.Pattern); err != nil {
return fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err)
}
}
return nil
}
// complianceRuleHead — та частина наявного правила, яку треба знати
// ДО того, як його переписувати.
type complianceRuleHead struct {
BuiltinKey string
Name string
Kind string
Pattern string
ConfigType string
// Selector — збережений селектор, уже РОЗІБРАНИЙ.
//
// Саме розібраний, а не текст і не прапорець «змінився» з бази.
// Історія цього поля варта рядка, бо помилка тут коштувала картини
// мережі.
//
// Спершу питання вирішувала база виразом
// `selector IS DISTINCT FROM $n::jsonb`. Здогад був правильний —
// jsonb порівнює за змістом, а не за формою запису, тож зайвий
// пробіл чи інший порядок ключів зміною не вважались. Але
// порівнювались два РІЗНІ описи того самого: міграція 0071 кладе
// `'{}'::jsonb`, а Go марширує store.Selector без `omitempty` —
// тобто завжди сім ключів зі значенням null. Для jsonb `{}` і
// `{"device_ids":null,…}` — різні значення. Отже, перше ж збереження
// вбудованого правила (вимкнути, знизити серйозність, дописати
// пораду) вважалося зміною селектора й СТИРАЛО результати прогону.
// Помилка одноразова на правило, тому непомітна; а стерта картина
// виглядає точнісінько як «у вас усе добре».
//
// Тепер порівнюються ЗНАЧЕННЯ: обидва селектори зводяться до
// канонічного вигляду (див. selectorJSON) і зіставляються там.
// Той самий клас помилки не вилізе, коли Selector отримає нове
// поле: канонізація перелічує поля явно, а порожні викидає.
Selector Selector
}
// selectorJSON — селектор у вигляді, що залежить лише від ЗНАЧЕННЯ.
//
// Порожні виміри до запису не потрапляють узагалі: «жодної групи» і
// «поля немає» — це одне й те саме твердження («до всіх»), і різниця
// між `null`, `[]` та відсутністю ключа не має означати різні селектори.
// Ключі мапи json.Marshal упорядковує сам, тож текст виходить сталий.
//
// Результат їде і в порівняння, і В БАЗУ: так селектор нормалізується
// при кожному збереженні, і окрема міграція для цього не потрібна —
// порівняння більше не залежить від того, у якій формі рядок лежить.
func selectorJSON(sel Selector) ([]byte, error) {
m := map[string]any{}
put := func(key string, v []string) {
if len(v) > 0 {
m[key] = v
}
}
put("device_ids", sel.DeviceIDs)
put("group_ids", sel.GroupIDs)
put("site_ids", sel.SiteIDs)
put("kinds", sel.Kinds)
put("template_ids", sel.TemplateIDs)
put("vendors", sel.Vendors)
if len(sel.Tags) > 0 {
m["tags"] = sel.Tags
}
return json.Marshal(m)
}
// selectorsEqual — чи це той самий склад хостів.
func selectorsEqual(a, b Selector) bool {
ja, err := selectorJSON(a)
if err != nil {
return false
}
jb, err := selectorJSON(b)
if err != nil {
return false
}
return string(ja) == string(jb)
}
// parseSelector читає селектор із того, що лежить у базі.
//
// Порожній текст — це «до всіх», а не помилка: колонка може бути NULL у
// рядку, який завели поза API.
func parseSelector(raw string) (Selector, error) {
var sel Selector
if strings.TrimSpace(raw) == "" {
return sel, nil
}
if err := json.Unmarshal([]byte(raw), &sel); err != nil {
return sel, err
}
return sel, nil
}
// complianceSubjectChanged — чи змінилось те, ПРО ЩО правило питає.
//
// Від цього залежить, чи стирати результати попереднього прогону. Досі
// стиралися завжди, і причина була правильна: зразок міг змінитись, а
// відповідь на питання, якого ніхто вже не ставить, гірша за її
// відсутність.
//
// Але під «завжди» потрапило й вимкнення правила. «Вимкнути в себе» —
// це головна дія над вбудованим правилом (0071, 1.1: клієнт, який
// свідомо тримає telnet у технологічній мережі), і платити за неї
// стертою картиною всієї мережі по цьому правилу немає за що: питання
// не змінилось, змінилось лише те, чи його ставлять. Те саме з
// перейменуванням, порадою «як виправити» й серйозністю — серйозність
// узагалі читається з правила на льоту (ListComplianceResults бере
// cr.severity джойном).
//
// Селектор сюди входить: він вирішує, ЯКІ хости під правилом, і після
// звуження старі результати лишились би про хости, яких правило вже не
// стосується.
func complianceSubjectChanged(cur complianceRuleHead, next ComplianceRule) bool {
return cur.Kind != next.Kind ||
cur.Pattern != next.Pattern ||
cur.ConfigType != next.ConfigType ||
!selectorsEqual(cur.Selector, next.Selector)
}
// checkBuiltinImmutable не дає переписати предмет перевірки вбудованого
// правила.
//
// ЩО САМЕ ЗАМКНЕНО: назва, вид, зразок і тип конфігу — тобто питання,
// яке правило ставить. ЩО ЛИШАЄТЬСЯ ВІДКРИТИМ: enabled, серйозність,
// селектор, опис і порада «як виправити» — тобто політика кабінету
// щодо цього питання. Розріз саме тут, а не деінде: 0071 завела копію
// на кабінет рівно для того, щоб один клієнт вимагав SSH-only скрізь, а
// інший свідомо тримав telnet у технологічній мережі. Це вибір про
// ЗАСТОСУВАННЯ, і його ніхто не забирає.
//
// ПРИЧИНА замка. За кожним вбудованим зразком стоїть доказ:
// ncm_compliance_builtin_test.go читає зразки з самої міграції й
// показує для кожного конфіг, де він мусить спрацювати, і конфіг, де
// не мусить. Переписаний руками зразок цього доказу не має, а значок
// «вбудоване» лишається — і в звіті, який кладуть аудитору, рядок
// «SNMP: типові community» означатиме вже щось інше, ніж написано в
// довіднику. Помилка тиха вдвічі: правило зі зразком, що не збігається
// ніколи, дає «порушень немає» — вигляд бездоганної мережі.
//
// НАСЛІДОК для людини: замість сірих полів без пояснення вона отримує
// кнопку «Зробити копію» — своє правило з тим самим зразком, без ключа
// й без чужої гарантії за спиною. Копію можна правити як завгодно.
//
// Це трохи вужче за те, що описує 0071 у 1.1 («людина може … підправити
// зразок»): міграція говорила про те, що builtin_key переживає UPDATE, і
// не про те, що редактор мусить такий UPDATE пропонувати. Ключ і далі
// переживає правку — просто предмет перевірки тепер не серед того, що
// правиться.
func checkBuiltinImmutable(cur complianceRuleHead, next ComplianceRule) error {
if cur.BuiltinKey == "" {
return nil
}
locked := []struct {
field string
was string
now string
}{
{"назву", cur.Name, next.Name},
{"вид умови", cur.Kind, next.Kind},
{"зразок", cur.Pattern, next.Pattern},
{"тип конфігу", cur.ConfigType, next.ConfigType},
}
for _, f := range locked {
if f.was != f.now {
return fmt.Errorf(
"%w: %s вбудованого правила (%s) змінити не можна — зробіть копію й правте її",
ErrInvalid, f.field, cur.BuiltinKey)
}
}
return nil
}
// ListComplianceRules — самі правила, БЕЗ лічильників знахідок.
//
// Лічильники Failed/Passed раніше рахувались тут же двома підзапитами по
// всій таблиці результатів — і в цьому була помилка, яку видно лише
// поруч із сусіднім екраном. Підзапити не знали ні про Scope, ні про
// видалені хости, тоді як «Знахідки» і звіт фільтруються і за тим, і за
// тим. Інженер філії бачив у списку «5 з 12 не пройшли», відкривав
// знахідки — там дві. Гірше за розбіжність те, що сам агрегат ГОВОРИТЬ
// про хости, яких людині бачити не належить.
//
// Тому лічильники більше не приходять із SQL: їх рахує
// ApplyComplianceCounts із тих самих рядків, які людина отримує в
// «Знахідках». Так вони збігаються не завдяки двом однаковим фільтрам, а
// тому, що це одне й те саме число. Та сама думка, що й у
// ComplianceReportOf, і з тієї ж причини.
func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]ComplianceRule, error) {
out := []ComplianceRule{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.id::text, r.name, COALESCE(r.description,''), r.kind::text,
r.pattern, r.severity::text, r.selector::text,
COALESCE(r.remediation,''), r.enabled, r.config_type,
COALESCE(r.builtin_key,'')
FROM ncm.compliance_rules r
WHERE r.tenant_id = $1
ORDER BY r.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var r ComplianceRule
var sel string
if err := rows.Scan(&r.ID, &r.Name, &r.Description, &r.Kind, &r.Pattern,
&r.Severity, &sel, &r.Remediation, &r.Enabled, &r.ConfigType,
&r.BuiltinKey); err != nil {
return err
}
if r.Selector, err = parseSelector(sel); err != nil {
return fmt.Errorf("правило %s: selector: %w", r.Name, err)
}
out = append(out, r)
}
return rows.Err()
})
return out, err
}
// ApplyComplianceCounts проставляє правилам Failed/Passed із тих самих
// результатів, які бачить людина.
//
// ПРИЧИНА окремої функції, а не count(*) у запиті: підрахунок мусить
// іти ПІСЛЯ відсіву за видимістю (Scope), який живе в обробнику HTTP.
// Порахований у SQL, він суперечив би переліку під собою — і суперечив
// би тихо, бо кожне з двох чисел саме по собі виглядає правдоподібно.
//
// НАСЛІДОК: список правил і вкладка «Знахідки» не можуть розійтись у
// принципі — вони рахують один і той самий зріз.
func ApplyComplianceCounts(rules []ComplianceRule, results []ComplianceResult) []ComplianceRule {
failed := map[string]int{}
passed := map[string]int{}
for _, x := range results {
if x.Passed {
passed[x.RuleID]++
} else {
failed[x.RuleID]++
}
}
out := make([]ComplianceRule, 0, len(rules))
for _, r := range rules {
r.Failed = failed[r.ID]
r.Passed = passed[r.ID]
out = append(out, r)
}
return out
}
// SaveComplianceRule створює або оновлює правило.
//
// Нове правило ЗАВЖДИ своє: builtin_key у INSERT не перелічений, тобто
// лишається NULL. Копія вбудованого правила («зробити копію й правити
// її») приходить сюди тим самим шляхом, що й правило, набране з нуля, —
// і саме тому вона не успадковує ані ключа, ані чужої гарантії за
// зразком.
func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r ComplianceRule) (string, error) {
if err := validateComplianceRule(r); err != nil {
return "", err
}
// Селектор пишемо в КАНОНІЧНІЙ формі, а не через json.Marshal самої
// структури. Причина й наслідок — у коментарі до complianceRuleHead.
// Коротко: Selector не має `omitempty`, тож звичайний Marshal завжди
// дає сім ключів із null, а міграція 0071 кладе `{}` — для jsonb це
// різні значення того самого селектора.
sel, err := selectorJSON(r.Selector)
if err != nil {
return "", err
}
var id string
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if r.ID == "" {
return tx.QueryRow(ctx, `
INSERT INTO ncm.compliance_rules
(tenant_id, name, description, kind, pattern, severity,
selector, remediation, enabled, config_type)
VALUES ($1,$2,NULLIF($3,''),$4::ncm.rule_kind,$5,$6::ncm.rule_severity,
$7::jsonb,NULLIF($8,''),$9,$10)
RETURNING id::text
`, tenantID, r.Name, r.Description, r.Kind, r.Pattern, r.Severity,
string(sel), r.Remediation, r.Enabled, r.ConfigType).Scan(&id)
}
id = r.ID
// Читаємо поточний стан ПІД БЛОКУВАННЯМ, у тій самій транзакції,
// що й UPDATE. Перевірити замок окремим запитом до транзакції
// означало б лишити щілину: між перевіркою й записом правило
// може стати вбудованим (накат 0071 на кабінет, створений
// раніше) — і саме той UPDATE, який замок мусив спинити,
// пройшов би.
var cur complianceRuleHead
var curSel string
err := tx.QueryRow(ctx, `
SELECT COALESCE(builtin_key,''), name, kind::text, pattern, config_type,
selector::text
FROM ncm.compliance_rules
WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, r.ID, tenantID).Scan(&cur.BuiltinKey, &cur.Name, &cur.Kind, &cur.Pattern,
&cur.ConfigType, &curSel)
if err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Селектор порівнюємо за ЗНАЧЕННЯМ, у Go. База вміє порівнювати
// jsonb за змістом, але зміст двох різних описів того самого
// селектора для неї різний: `{}` (0071) проти семи ключів із
// null (json.Marshal без omitempty). Саме на цьому перше
// збереження будь-якого вбудованого правила мовчки стирало
// результати прогону.
if cur.Selector, err = parseSelector(curSel); err != nil {
return fmt.Errorf("правило %s: збережений selector: %w", cur.Name, err)
}
if err := checkBuiltinImmutable(cur, r); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
UPDATE ncm.compliance_rules SET
name = $3, description = NULLIF($4,''), kind = $5::ncm.rule_kind,
pattern = $6, severity = $7::ncm.rule_severity, selector = $8::jsonb,
remediation = NULLIF($9,''), enabled = $10, config_type = $11
WHERE id = $1 AND tenant_id = $2
`, r.ID, tenantID, r.Name, r.Description, r.Kind, r.Pattern, r.Severity,
string(sel), r.Remediation, r.Enabled, r.ConfigType)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
// Результати стираємо, лише якщо змінився ПРЕДМЕТ перевірки:
// старі відповіді на питання, якого вже ніхто не ставить, гірші
// за їх відсутність. Вимкнення, перейменування, інша серйозність
// чи інша порада питання не змінюють — і платити за них стертою
// картиною мережі немає за що (див. complianceSubjectChanged).
if complianceSubjectChanged(cur, r) {
_, err = tx.Exec(ctx, `DELETE FROM ncm.compliance_results WHERE rule_id = $1`, r.ID)
return err
}
return nil
})
return id, err
}
// DeleteComplianceRule видаляє СВОЄ правило.
//
// Вбудоване не видаляється, і це не педантизм. Видалене правило зникає
// зі списку — тобто кабінет, у якому хтось прибрав «Telnet вимкнено»,
// виглядає рівно так само, як кабінет, де telnet скрізь закритий: у
// звіті немає ні рядка про telnet, ні згадки, що його ніхто не питав.
// Вимкнене правило лишається видимим і потрапляє у звіт окремим
// розділом «вимкнені» — відсутність, яку видно, коштує дешевше за
// відсутність, якої не видно (та сама думка, що в шапці 0071 про
// порожній розділ і про вигляд покриття).
//
// Друга, дрібніша причина: ncm.builtin_compliance_apply() довкладає
// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось
// би при наступному накаті довідника — уже ввімкненим і без сліду, що
// його свідомо прибирали.
// ComplianceRuleRef — те, що видалення розповідає про правило журналу
// аудиту. Читається до DELETE: після нього самої вимоги немає ніде, і
// «правило 7f3a…» у журналі не відповідає на питання, чого саме
// перестали перевіряти.
type ComplianceRuleRef struct {
ID string
Name string
Severity string
Pattern string
}
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) (ComplianceRuleRef, error) {
ref := ComplianceRuleRef{ID: id}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var builtin string
err := tx.QueryRow(ctx, `
SELECT COALESCE(builtin_key,''), name, severity::text, pattern
FROM ncm.compliance_rules
WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, id, tenantID).Scan(&builtin, &ref.Name, &ref.Severity, &ref.Pattern)
if err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if builtin != "" {
return fmt.Errorf("%w: вбудоване правило (%s) не видаляється — вимкніть його, "+
"щоб у звіті лишився слід, що вимогу знято свідомо", ErrInvalid, builtin)
}
tag, err := tx.Exec(ctx,
`DELETE FROM ncm.compliance_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
return ref, err
}
// ListComplianceResults — знахідки, спершу провалені й найважчі.
//
// ТІЛЬКИ ЧИННІ ВІДПОВІДІ. Два фільтри тут не косметичні:
//
// - `cr.enabled` — результати ВИМКНЕНОГО правила не віддаються. Прогін
// вимкнене правило пропускає й старих його результатів не чіпає
// (і правильно робить: вимкнути вбудоване правило «в себе» — головна
// дія над ним, і платити за неї стертою картиною мережі немає за що,
// див. complianceSubjectChanged). Але доки ці рядки віддавалися
// нарівні з рештою, вимкнене правило рахувалось у «Порушень N»
// назавжди — тобто підсумок відповідав на питання, якого вже ніхто
// не ставить. Рядки лишаються в базі; повернути правило й прогнати
// заново — і картина повертається.
//
// - `d.deleted_at IS NULL` — хост, якого видалили, більше не відповідач.
// complianceTargets його вже не бере, тож нових результатів у нього
// не з'явиться, а старі висіли б вічно. Саме м'яке видалення, а не
// фізичне: фізичне забирає рядок каскадом по FK.
//
// НАСЛІДОК для звіту: ComplianceReportOf рахує підсумок із цієї ж
// вибірки, тож «Порушень N» більше не містить відповідей на зняті
// питання.
func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, onlyFailed bool) ([]ComplianceResult, error) {
out := []ComplianceResult{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT r.rule_id::text, cr.name, cr.severity::text,
r.device_id::text, d.name, COALESCE(r.config_id::text,''),
r.passed, COALESCE(r.details->>'line',''),
COALESCE((r.details->>'line_number')::int, 0),
r.checked_at
FROM ncm.compliance_results r
JOIN ncm.compliance_rules cr ON cr.id = r.rule_id
JOIN inv.devices d ON d.id = r.device_id
WHERE r.tenant_id = $1 AND ($2 = false OR NOT r.passed)
AND cr.enabled
AND d.deleted_at IS NULL
ORDER BY r.passed,
array_position(ARRAY['critical','high','medium','low','info'],
cr.severity::text),
d.name
`, tenantID, onlyFailed)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var x ComplianceResult
if err := rows.Scan(&x.RuleID, &x.RuleName, &x.Severity, &x.DeviceID,
&x.DeviceName, &x.ConfigID, &x.Passed, &x.Line, &x.LineNumber,
&x.CheckedAt); err != nil {
return err
}
out = append(out, x)
}
return rows.Err()
})
return out, err
}
// ComplianceStat — підсумок прогону.
type ComplianceStat struct {
Rules int `json:"rules"`
// Checks — скільки пар «правило + хост» справді перевірено. Не число
// хостів: одне правило може накрити десять, а десять правил — один.
Checks int `json:"checks"`
Failed int `json:"failed"`
// Skipped — пари, де в хоста ще немає зібраного конфігу.
Skipped int `json:"skipped"`
// Findings — що саме прогін побачив на кожній перевіреній парі.
//
// Потрібне не звіту, а алертам: прогін — це та мить, коли
// відповідність стає подією, і єдине місце, де відомо і про нове
// порушення, і про те, що старе зникло. Пропущені пари сюди не
// потрапляють: «конфіг ще не збирали» не є ні порушенням, ні його
// зняттям.
Findings []ComplianceFinding `json:"-"`
}
// ComplianceFinding — вислід однієї пари «правило + хост».
type ComplianceFinding struct {
RuleID string
RuleName string
Severity string
DeviceID string
Passed bool
Line string
LineNumber int
}
// RunCompliance проганяє всі увімкнені правила по останніх конфігах.
//
// Останній конфіг, а не поточний стан пристрою: перевірка читає те, що
// вже зібрано, і не створює жодної сесії до заліза. Тому її можна
// запускати скільки завгодно й одразу після зміни правила — на відміну
// від опитування, вона нічого не коштує мережі.
//
// Хост без жодного зібраного конфігу пропускається, а не рахується
// проваленим: «ще не збирали» і «не відповідає вимозі» — різні речі, і
// плутати їх означає ховати справжні знахідки серед шуму.
func (s *Store) RunCompliance(ctx context.Context, tenantID string, ring *crypto.Keyring) (ComplianceStat, error) {
var stat ComplianceStat
rules, err := s.ListComplianceRules(ctx, tenantID)
if err != nil {
return stat, err
}
for _, r := range rules {
if !r.Enabled {
continue
}
stat.Rules++
devices, err := s.complianceTargets(ctx, tenantID, r.Selector)
if err != nil {
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
}
// Прибираємо відповіді про хости, яких правило ВЖЕ не стосується:
// вони випали із селектора або їх видалили з інвентарю.
//
// Прогін — єдина мить, коли повний склад чинних питань відомий, і
// тому чистка стоїть саме тут, а не в SaveComplianceRule: селектор
// міг лишитись тим самим, а склад хостів під ним — змінитись
// (хост вийшов із групи, у хоста змінили вендора, хост видалили).
//
// НАСЛІДОК, якби не чистили: правило, звужене до однієї групи,
// й далі показувало б знахідки по всьому парку — і кожна з них
// виглядала б як чинна.
if err := s.pruneComplianceResults(ctx, tenantID, r.ID, devices); err != nil {
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
}
var re *regexp.Regexp
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
re, err = regexp.Compile(r.Pattern)
if err != nil {
// Зразок перевіряється при збереженні, тож сюди можна
// потрапити лише після правки в обхід API.
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
}
}
for _, d := range devices {
configID, body, err := s.latestConfigBody(ctx, tenantID, d, r.ConfigType, ring)
if err != nil || configID == "" {
stat.Skipped++
continue
}
stat.Checks++
passed, line, num := checkCompliance(r, re, body)
if !passed {
stat.Failed++
}
stat.Findings = append(stat.Findings, ComplianceFinding{
RuleID: r.ID, RuleName: r.Name, Severity: r.Severity,
DeviceID: d, Passed: passed, Line: line, LineNumber: num,
})
if err := s.saveComplianceResult(ctx, tenantID, r.ID, d, configID, passed, line, num); err != nil {
return stat, err
}
}
}
return stat, nil
}
// checkCompliance застосовує одне правило до тексту конфігу.
//
// Порядково, а не по всьому тексту: знахідку треба показати людині, і
// «рядок 412» — відповідь, з якою можна щось зробити, на відміну від
// «десь у конфігу є».
func checkCompliance(r ComplianceRule, re *regexp.Regexp, body string) (bool, string, int) {
lines := strings.Split(body, "\n")
for i, ln := range lines {
var hit bool
switch r.Kind {
case "must_contain", "must_not_contain":
hit = strings.Contains(ln, r.Pattern)
case "regex_match", "regex_absent":
hit = re.MatchString(ln)
}
if !hit {
continue
}
switch r.Kind {
case "must_contain", "regex_match":
// Знайшли те, що мало бути — далі шукати нема чого.
return true, strings.TrimSpace(ln), i + 1
default:
// Знайшли те, чого бути не мало — це і є знахідка.
return false, strings.TrimSpace(ln), i + 1
}
}
// Нічого не знайшли: для «має бути» це провал, для «не має бути» —
// успіх.
switch r.Kind {
case "must_contain", "regex_match":
return false, "", 0
default:
return true, "", 0
}
}
func (s *Store) complianceTargets(ctx context.Context, tenantID string, sel Selector) ([]string, error) {
a := &args{}
a.add(tenantID)
where, err := s.selectorSQL(sel, a, "d.id")
if err != nil {
return nil, err
}
var out []string
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT d.id::text FROM inv.devices d
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
`+where+`
ORDER BY d.name
`, a.vals...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
out = append(out, id)
}
return rows.Err()
})
return out, err
}
// pruneComplianceResults лишає в результатах правила лише ті хости, які
// під ним зараз.
//
// `<> ALL(порожній масив)` — істина, тож правило без жодного хоста
// втрачає всі свої результати. Це навмисно: правило, під яке не підпадає
// ніхто, не має чинних відповідей, і старі показували б мережу, якої
// воно вже не питає.
func (s *Store) pruneComplianceResults(ctx context.Context, tenantID, ruleID string, keep []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
DELETE FROM ncm.compliance_results
WHERE tenant_id = $1 AND rule_id = $2
AND device_id <> ALL($3::uuid[])
`, tenantID, ruleID, nonNilIDs(keep))
return err
})
}
func (s *Store) latestConfigBody(ctx context.Context, tenantID, deviceID, configType string, ring *crypto.Keyring) (string, string, error) {
if configType == "" {
configType = "running"
}
var configID string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Найсвіжіша версія ПОТРІБНОГО типу, а не просто найсвіжіша.
//
// Різниця з'являється, щойно в хоста стає два типи. Хост, у
// якого конфігу цього типу немає взагалі, під правило просто не
// підпадає — і це не порушення: правило про sshd_config не має
// що сказати про комутатор.
return tx.QueryRow(ctx, `
SELECT id::text FROM ncm.configs
WHERE device_id = $1 AND tenant_id = $2 AND config_type = $3
ORDER BY collected_at DESC LIMIT 1
`, deviceID, tenantID, configType).Scan(&configID)
})
if err != nil {
if isNoRows(err) {
return "", "", nil
}
return "", "", err
}
body, _, err := s.ConfigBody(ctx, tenantID, configID, ring)
if err != nil {
return "", "", err
}
return configID, body, nil
}
func (s *Store) saveComplianceResult(
ctx context.Context,
tenantID, ruleID, deviceID, configID string,
passed bool, line string, num int,
) error {
details, err := json.Marshal(map[string]any{"line": line, "line_number": num})
if err != nil {
return err
}
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO ncm.compliance_results
(tenant_id, rule_id, device_id, config_id, passed, details, checked_at)
VALUES ($1,$2,$3,$4,$5,$6::jsonb, now())
ON CONFLICT (rule_id, device_id) DO UPDATE SET
config_id = EXCLUDED.config_id,
passed = EXCLUDED.passed,
details = EXCLUDED.details,
checked_at = now()
`, tenantID, ruleID, deviceID, nullUUID(configID), passed, string(details))
return err
})
}