За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
869 lines
42 KiB
Go
869 lines
42 KiB
Go
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
|
||
})
|
||
}
|