Перший справжній прогін на живій мережі дав 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. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
563 lines
24 KiB
Go
563 lines
24 KiB
Go
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()
|
||
}
|