Netpulse_SasS/server/internal/store/ncm_compliance_report.go
byrsapty 6de3565bbe
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s
Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.

РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.

Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.

ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:

1. Перше збереження будь-якого вбудованого правила стирало результати.
   Селектор порівнювався в базі, але порівнювались різні представлення
   одного значення: міграція кладе {}, Go марширує сім ключів із null.
   Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
   файл відкриває аудитор. Одне місце екранування на всі три звіти:
   дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
   рахувались як чинні. Три заслони: фільтр у списку, прибирання при
   прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
   інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
   число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
2026-08-28 13:40:40 +03:00

563 lines
24 KiB
Go
Raw 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 (
"encoding/csv"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
)
// Звіт про відповідність конфігів — те, що показують керівнику або
// аудитору.
//
// ЧОМУ ЗВІТ НЕ ДОРІВНЮЄ ЕКРАНУ ЗНАХІДОК
//
// На екрані знахідок відповідь на питання «що зараз болить»: список
// порушень, найважчі зверху. Це робочий інструмент інженера, і для нього
// він правильний. Аудитор ставить інше питання — «проти чого це
// перевірялось і коли». Той самий список без відповіді на нього не
// вартий нічого: за місяць правила зміняться, і два роздруки з різними
// числами не буде чим пояснити. «18 порушень» у травні й «4» у червні —
// це або робота інженерів, або хтось вимкнув чотирнадцять правил, і за
// самими числами ці випадки не відрізняються.
//
// Тому шапка звіту несе СКЛАД ПРАВИЛ ЦІЛКОМ, а не лише їх кількість:
// назву, вид, зразок, серйозність, тип конфігу, стан і походження
// (вбудоване чи своє). Це і є те, що робить файл документом.
//
// ЩО ЩЕ МУСИТЬ БУТИ ВИДНО
//
// 1. ВИМКНЕНІ правила — окремо й поіменно. Вимкнене правило не дає
// порушень, і звіт без цього розділу читається як «вимога виконана».
// 2. Правила, які ЩЕ НЕ ПРОГАНЯЛИСЬ. Нуль порушень у такого правила
// означає «не питали», а не «все гаразд».
// 3. Хости, яких правило не торкнулось, — через тип конфігу або
// селектор. Прогін рахує їх пропущеними, і саме вони — найдешевший
// спосіб зробити звіт зеленим випадково.
//
// ЧОМУ ЗВІТ ЗБИРАЄТЬСЯ ЧИСТОЮ ФУНКЦІЄЮ
//
// ComplianceReportOf нічого не читає з бази: правила й результати їй
// подають. Причина практична — обробник HTTP спершу прибирає з
// результатів хости поза видимістю оператора (див. handleListComplianceResults),
// і підсумок треба рахувати ПІСЛЯ цього. Порахований у SQL, він
// суперечив би переліку під собою: «18 порушень» у шапці й дванадцять
// рядків нижче.
// ComplianceReportRule — один рядок складу правил у шапці звіту.
type ComplianceReportRule struct {
Name string `json:"name"`
Kind string `json:"kind"`
Pattern string `json:"pattern"`
Severity string `json:"severity"`
ConfigType string `json:"config_type"`
Enabled bool `json:"enabled"`
Remediation string `json:"remediation,omitempty"`
// Origin — «вбудоване (ключ)» або «своє». Аудитор має бачити, за
// котрими правилами стоїть довідник постачальника, а котрі написав
// сам клієнт.
BuiltinKey string `json:"builtin_key,omitempty"`
// Checked — скільки пар «правило + хост» це правило дало.
// Нуль при Enabled — правило не проганялось або не знайшло жодного
// хоста з конфігом потрібного типу.
Checked int `json:"checked"`
Failed int `json:"failed"`
}
// ComplianceViolation — одна знахідка з порадою.
//
// Порада (remediation) тут поруч із рядком, а не в окремій таблиці
// правил: звіт читає той, хто цього конфігу ніколи не бачив, і змушувати
// його гортати назад по кожному рядку означає, що він цього не робитиме.
type ComplianceViolation struct {
DeviceName string `json:"device_name"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
ConfigType string `json:"config_type"`
Line string `json:"line,omitempty"`
LineNumber int `json:"line_number,omitempty"`
Remediation string `json:"remediation,omitempty"`
BuiltinKey string `json:"builtin_key,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
// ComplianceReport — звіт цілком.
type ComplianceReport struct {
GeneratedAt time.Time `json:"generated_at"`
// GeneratedBy — хто натиснув. Звіт без автора неможливо перепитати.
GeneratedBy string `json:"generated_by,omitempty"`
// LastCheckAt — коли востаннє проганялась перевірка. Порожньо —
// не проганялась жодного разу, і тоді нулі в звіті означають
// «не питали».
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
// PartialScope — оператор бачить не весь парк.
//
// Найважливіше застереження звіту. Інженер філії, який вивантажить
// звіт, отримає правду про свої хости — і документ, який виглядає
// як правда про всю мережу.
PartialScope bool `json:"partial_scope"`
RulesTotal int `json:"rules_total"`
RulesEnabled int `json:"rules_enabled"`
RulesBuiltin int `json:"rules_builtin"`
RulesNeverRun int `json:"rules_never_run"`
Devices int `json:"devices"`
DevicesFailed int `json:"devices_failed"`
Checks int `json:"checks"`
Failed int `json:"failed"`
// BySeverity — порушення за серйозністю, у сталому порядку від
// найважчої. Мапа тут була б зручнішою й дала б довільний порядок
// у JSON — тобто звіт, що при кожному відкритті виглядає інакше.
BySeverity []ComplianceSeverityCount `json:"by_severity"`
Rules []ComplianceReportRule `json:"rules"`
Violations []ComplianceViolation `json:"violations"`
}
// ComplianceSeverityCount — скільки порушень якої серйозності.
type ComplianceSeverityCount struct {
Severity string `json:"severity"`
Count int `json:"count"`
}
// csvBOM — мітка порядку байтів на початку вивантаження.
//
// Без неї український Excel читає UTF-8 як cp1251, і всі назви хостів
// перетворюються на кракозябри; решта інструментів BOM пропускає. Іменем,
// а не символом у рядку: сам символ невидимий, і в один прекрасний день
// його «приберуть як зайвий пробіл» разом із читабельністю звіту.
const csvBOM = "\ufeff"
// complianceSeverityOrder — від найважчої. Той самий порядок, що в
// ORDER BY у ListComplianceResults: звіт і екран мусять шикувати
// однаково, інакше їх не звести очима.
var complianceSeverityOrder = []string{"critical", "high", "medium", "low", "info"}
var complianceSeverityLabel = map[string]string{
"critical": "критична",
"high": "висока",
"medium": "середня",
"low": "низька",
"info": "інформація",
}
var complianceKindLabel = map[string]string{
"must_contain": "має містити",
"must_not_contain": "не має містити",
"regex_match": "збіг за виразом",
"regex_absent": "немає збігу за виразом",
}
var complianceConfigTypeLabel = map[string]string{
"running": "конфіг обладнання (running)",
"startup": "конфіг при завантаженні (startup)",
"files": "конфіг-файли сервера (files)",
}
// ComplianceSeverityLabel — підпис серйозності для звіту й інтерфейсу.
func ComplianceSeverityLabel(s string) string {
if l, ok := complianceSeverityLabel[s]; ok {
return l
}
return s
}
func complianceLabel(m map[string]string, k string) string {
if l, ok := m[k]; ok {
return l
}
return k
}
// ComplianceReportOf збирає звіт із того, що людині справді видно.
//
// results мають бути ПОВНИМИ (і пройдені, і провалені): без пройдених
// неможливо сказати, скільки пар перевірено, — а «18 порушень» без
// знаменника не означає нічого. Скільки це від шести перевірок і скільки
// від шестисот — різні новини.
func ComplianceReportOf(
at time.Time,
by string,
partialScope bool,
rules []ComplianceRule,
results []ComplianceResult,
) ComplianceReport {
rep := ComplianceReport{
GeneratedAt: at,
GeneratedBy: by,
PartialScope: partialScope,
RulesTotal: len(rules),
Rules: make([]ComplianceReportRule, 0, len(rules)),
Violations: []ComplianceViolation{},
}
// Результати рахуються по правилах, а не беруться з
// ComplianceRule.Failed/Passed: ті лічильники приходять із SQL і
// нічого не знають про видимість хостів. Перерахунок тут — єдиний
// спосіб не дати шапці суперечити переліку.
type ruleStat struct{ checked, failed int }
stats := map[string]*ruleStat{}
rulesByID := map[string]ComplianceRule{}
for _, r := range rules {
stats[r.ID] = &ruleStat{}
rulesByID[r.ID] = r
}
devices := map[string]bool{}
devicesFailed := map[string]bool{}
bySev := map[string]int{}
for _, x := range results {
st, ok := stats[x.RuleID]
if !ok {
// Правило вже видалили, а результат ще лежить. Такого бути
// не має (FK ON DELETE CASCADE), але рядок без правила в
// звіт не потрапляє: пояснити його не буде чим.
continue
}
// Результат ВИМКНЕНОГО правила у звіт не йде — ні в підсумок, ні
// в перелік порушень.
//
// ПРИЧИНА: «Порушень N» — це відповідь на питання, які ставлять
// ЗАРАЗ. Вимкнене правило не питає нічого (RunCompliance його
// пропускає), тож його старі знахідки не оновлюються ніколи й
// лишались би в підсумку назавжди. Правило при цьому НЕ зникає:
// склад правил нижче покаже його поіменно як вимкнене — саме
// цієї видимості й вимагає шапка цього файла.
//
// Другий заслін до фільтра в ListComplianceResults, і навмисно:
// звіт — це документ, і він не має покладатись на те, що його
// нагодували правильним зрізом.
src := rulesByID[x.RuleID]
if !src.Enabled {
continue
}
st.checked++
devices[x.DeviceID] = true
rep.Checks++
if x.CheckedAt.After(timeOrZero(rep.LastCheckAt)) {
t := x.CheckedAt
rep.LastCheckAt = &t
}
if x.Passed {
continue
}
st.failed++
rep.Failed++
devicesFailed[x.DeviceID] = true
bySev[x.Severity]++
rep.Violations = append(rep.Violations, ComplianceViolation{
DeviceName: x.DeviceName,
RuleName: x.RuleName,
Severity: x.Severity,
ConfigType: src.ConfigType,
Line: x.Line,
LineNumber: x.LineNumber,
Remediation: src.Remediation,
BuiltinKey: src.BuiltinKey,
CheckedAt: x.CheckedAt,
})
}
rep.Devices = len(devices)
rep.DevicesFailed = len(devicesFailed)
for _, sev := range complianceSeverityOrder {
// Нулі теж у переліку: «критичних 0» — це відповідь, а
// відсутність рядка «критична» читається як «не перевіряли».
rep.BySeverity = append(rep.BySeverity,
ComplianceSeverityCount{Severity: sev, Count: bySev[sev]})
}
for _, r := range rules {
st := stats[r.ID]
if r.Enabled {
rep.RulesEnabled++
}
if r.BuiltinKey != "" {
rep.RulesBuiltin++
}
if st.checked == 0 {
rep.RulesNeverRun++
}
rep.Rules = append(rep.Rules, ComplianceReportRule{
Name: r.Name,
Kind: r.Kind,
Pattern: r.Pattern,
Severity: r.Severity,
ConfigType: r.ConfigType,
Enabled: r.Enabled,
Remediation: r.Remediation,
BuiltinKey: r.BuiltinKey,
Checked: st.checked,
Failed: st.failed,
})
}
// Порушення шикуються за серйозністю, потім за хостом, потім за
// правилом. Сталий порядок — не косметика: два вивантаження того
// самого стану мають давати той самий файл, інакше їх не порівняти
// diff-ом, а саме так їх і порівнюють.
sort.SliceStable(rep.Violations, func(i, j int) bool {
a, b := rep.Violations[i], rep.Violations[j]
if ai, bi := complianceSeverityRank(a.Severity), complianceSeverityRank(b.Severity); ai != bi {
return ai < bi
}
if a.DeviceName != b.DeviceName {
return a.DeviceName < b.DeviceName
}
return a.RuleName < b.RuleName
})
sort.SliceStable(rep.Rules, func(i, j int) bool {
return rep.Rules[i].Name < rep.Rules[j].Name
})
return rep
}
func complianceSeverityRank(s string) int {
for i, x := range complianceSeverityOrder {
if x == s {
return i
}
}
return len(complianceSeverityOrder)
}
func timeOrZero(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
// ---------------------------------------------------------------------
// Вивантаження
// ---------------------------------------------------------------------
// ComplianceReportCSV пише звіт у потік.
//
// Домовленості формату ті самі, що в SLAReportCSV, і не тому, що
// «однаково»: обидва файли кладуть в одну папку до договору й відкривають
// одним Excel. BOM — інакше український Excel читає UTF-8 як cp1251 і
// назви хостів стають кракозябрами. `sep=;` — інакше той самий Excel бере
// кому роздільником полів, а решта світу цей рядок пропускає як
// коментар. CRLF — те, чого чекає Excel.
//
// ТРИ РОЗДІЛИ В ОДНОМУ ФАЙЛІ, а не три файли. Порушення без складу
// правил — це числа без питання, до якого вони відповідь; склад правил
// без порушень — політика без наслідку. Роздільник між ними — порожній
// рядок: Excel його показує, а імпортери пропускають.
//
// ПИШЕМО ЧЕРЕЗ safeCSV, а не прямо в csv.Writer. У клітинки їде сирий
// рядок конфігу пристрою (`v.Line`), зразок правила, порада та імена
// хостів — тобто текст, який Excel прочитав би як формулу, якби він
// починався з `=`, `+`, `-` чи `@`. Чому це важливо саме тут і чому
// екранування живе одним місцем на всі три звіти — у csv_safe.go.
func ComplianceReportCSV(w io.Writer, rep ComplianceReport) error {
if _, err := io.WriteString(w, csvBOM); err != nil {
return err
}
if _, err := io.WriteString(w, "sep=;\r\n"); err != nil {
return err
}
raw := csv.NewWriter(w)
raw.Comma = ';'
raw.UseCRLF = true
cw := newSafeCSV(raw)
// --- шапка: за що і коли ---
head := [][]string{
{"Звіт", "Відповідність конфігів вимогам"},
{"Сформовано", rep.GeneratedAt.Format(time.RFC3339)},
}
if rep.GeneratedBy != "" {
head = append(head, []string{"Сформував", rep.GeneratedBy})
}
if rep.LastCheckAt != nil {
head = append(head, []string{"Остання перевірка", rep.LastCheckAt.Format(time.RFC3339)})
} else {
head = append(head, []string{"Остання перевірка",
"не проводилась — нулі нижче означають «не питали», а не «все гаразд»"})
}
head = append(head,
[]string{"Правил усього", strconv.Itoa(rep.RulesTotal)},
[]string{"З них увімкнено", strconv.Itoa(rep.RulesEnabled)},
[]string{"З них вбудованих", strconv.Itoa(rep.RulesBuiltin)},
[]string{"Правил без жодної перевірки", strconv.Itoa(rep.RulesNeverRun)},
[]string{"Хостів перевірено", strconv.Itoa(rep.Devices)},
[]string{"З них із порушеннями", strconv.Itoa(rep.DevicesFailed)},
[]string{"Перевірок (правило × хост)", strconv.Itoa(rep.Checks)},
[]string{"Порушень", strconv.Itoa(rep.Failed)},
)
for _, s := range rep.BySeverity {
head = append(head, []string{"Порушень, " + ComplianceSeverityLabel(s.Severity),
strconv.Itoa(s.Count)})
}
if rep.PartialScope {
head = append(head, []string{"Застереження",
"звіт охоплює лише хости, видимі тому, хто його сформував, — це не весь парк"})
}
if rep.RulesNeverRun > 0 {
head = append(head, []string{"Застереження",
"частина правил не дала жодної перевірки: або вимкнені, або в жодного хоста " +
"немає конфігу потрібного типу, або не підпав селектор"})
}
for _, row := range head {
if err := cw.Write(row); err != nil {
return err
}
}
if err := cw.Write(nil); err != nil {
return err
}
// --- склад правил ---
if err := cw.Write([]string{"СКЛАД ПРАВИЛ НА МОМЕНТ ЗВІТУ"}); err != nil {
return err
}
if err := cw.Write([]string{
"Правило", "Походження", "Стан", "Серйозність", "Умова", "Зразок",
"Тип конфігу", "Перевірок", "Порушень", "Як виправити",
}); err != nil {
return err
}
for _, r := range rep.Rules {
state := "вимкнено"
if r.Enabled {
state = "увімкнено"
if r.Checked == 0 {
// Стан, який інакше нічим не відрізнити від «усе добре».
state = "увімкнено, але жодної перевірки"
}
}
origin := "своє"
if r.BuiltinKey != "" {
origin = "вбудоване (" + r.BuiltinKey + ")"
}
if err := cw.Write([]string{
r.Name,
origin,
state,
ComplianceSeverityLabel(r.Severity),
complianceLabel(complianceKindLabel, r.Kind),
r.Pattern,
complianceLabel(complianceConfigTypeLabel, r.ConfigType),
strconv.Itoa(r.Checked),
strconv.Itoa(r.Failed),
r.Remediation,
}); err != nil {
return err
}
}
if err := cw.Write(nil); err != nil {
return err
}
// --- порушення ---
if err := cw.Write([]string{"ПОРУШЕННЯ"}); err != nil {
return err
}
if err := cw.Write([]string{
"Хост", "Правило", "Серйозність", "Тип конфігу",
"Рядок", "Що знайдено", "Як виправити", "Перевірено",
}); err != nil {
return err
}
if len(rep.Violations) == 0 {
// Порожній розділ мовчки читається як «порушень немає». Якщо
// перевірка не проводилась або всі правила вимкнені — це не те
// саме, і сказати про це треба в тому ж місці, де людина шукає
// список.
note := "порушень немає"
if rep.Checks == 0 {
note = "перевірок не було — це не «порушень немає»"
}
if err := cw.Write([]string{note}); err != nil {
return err
}
}
for _, v := range rep.Violations {
line := v.Line
num := ""
if v.LineNumber > 0 {
num = strconv.Itoa(v.LineNumber)
}
if line == "" {
// Для «має бути» знахідка — саме ВІДСУТНІСТЬ рядка.
// Порожня клітинка тут читалась би як недогляд.
line = "рядка немає в конфігу — саме це й порушення"
}
if err := cw.Write([]string{
v.DeviceName,
v.RuleName,
ComplianceSeverityLabel(v.Severity),
complianceLabel(complianceConfigTypeLabel, v.ConfigType),
num,
line,
v.Remediation,
v.CheckedAt.Format(time.RFC3339),
}); err != nil {
return err
}
}
// --- кінцевий маркер ---
//
// ПРИЧИНА. Звіт іде в потік уже після того, як пішов статус 200:
// помилка запису посеред стріму нікуди не подінеться, обробник її
// лише запише в журнал (handleComplianceReportCSV), а на диску в
// людини лишиться коротший файл. Обрізаний CSV нічим не
// відрізняється від повного: він відкривається, він читається, у
// ньому просто менше порушень.
//
// НАСЛІДОК маркера: файл без цього рядка видно як неповний — і видно
// тому, хто його відкрив, а не лише тому, хто читає журнал сервера.
// Число тут те саме, що в шапці: якщо рядків порушень нижче менше,
// ніж обіцяно, файл обірвався.
if err := cw.Write(nil); err != nil {
return err
}
if err := cw.Write([]string{"КІНЕЦЬ ЗВІТУ",
"порушень у файлі: " + strconv.Itoa(len(rep.Violations)),
"немає цього рядка — файл обірвався, читати його як повний не можна"}); err != nil {
return err
}
cw.Flush()
return cw.Error()
}
// ComplianceReportFileName — ім'я файла вивантаження.
//
// Дата в імені обов'язкова: у папці лежатиме десяток таких файлів, і
// «compliance.csv» серед них не означає нічого — а саме розрізнити два
// звіти за різні дні тут і треба.
func ComplianceReportFileName(rep ComplianceReport) string {
return fmt.Sprintf("compliance-%s.csv", rep.GeneratedAt.UTC().Format("2006-01-02-1504"))
}
// ComplianceReportSummaryLine — той самий підсумок одним рядком.
//
// Рівно та форма, у якій його читають уголос по телефону й вставляють у
// лист: «правил 20 · перевірок 28 · ПОРУШЕНЬ 18». Один опис на сервер і
// клієнт, щоб два підрахунки не розійшлись у третьому знаку.
func ComplianceReportSummaryLine(rep ComplianceReport) string {
var b strings.Builder
fmt.Fprintf(&b, "правил %d · перевірок %d · порушень %d",
rep.RulesTotal, rep.Checks, rep.Failed)
if rep.RulesNeverRun > 0 {
fmt.Fprintf(&b, " · без перевірки %d", rep.RulesNeverRun)
}
return b.String()
}