diff --git a/server/API.md b/server/API.md index 9262ccc..a98f248 100644 --- a/server/API.md +++ b/server/API.md @@ -171,6 +171,94 @@ TimescaleDB рахує згортки), **закривається**: рахує (початок періоду старший за збережену історію), `unknown_tz`, `device_purged`. +### Відповідність конфігів + +| Метод | Шлях | Призначення | +|-------|------|-------------| +| `GET` | `/api/v1/ncm/compliance/rules` | правила з підсумком останньої перевірки (`ncm:read`) | +| `POST` | `/api/v1/ncm/compliance/rules` | створити СВОЄ правило (`ncm:write`) | +| `PUT` | `/api/v1/ncm/compliance/rules/{id}` | змінити правило (`ncm:write`) | +| `DELETE` | `/api/v1/ncm/compliance/rules/{id}` | видалити СВОЄ правило (`ncm:write`) | +| `GET` | `/api/v1/ncm/compliance/results` | знахідки; `?failed=1` — лише порушення (`ncm:read`) | +| `POST` | `/api/v1/ncm/compliance/run` | прогін по вже зібраних конфігах (`ncm:read`) | +| `GET` | `/api/v1/ncm/compliance/probe-devices` | на чому перевірити зразок + вендори парку; `?config_type=` (`ncm:read`) | +| `POST` | `/api/v1/ncm/compliance/probe` | **прогнати незбережений зразок** по конфігу одного хоста (`ncm:read`) | +| `GET` | `/api/v1/ncm/compliance/report` | звіт: зведення, склад правил, порушення з порадами (`ncm:read`) | +| `GET` | `/api/v1/ncm/compliance/report.csv` | те саме вивантаженням (`ncm:read`) | + +**Вбудовані правила.** Кабінет отримує ВЛАСНУ копію кожного правила з +довідника `ncm.builtin_compliance` (0071); копію впізнають за +`builtin_key`. У таких правил замкнено предмет перевірки — `name`, +`kind`, `pattern`, `config_type`: за вбудованим зразком стоїть перевірка +на справжніх конфігах кількох вендорів, а переписаний руками зразок +цього доказу вже не має й лишався б позначеним як вбудований. Спроба +змінити замкнене поле — `400 invalid`. Решта полів (`enabled`, +`severity`, `selector`, `description`, `remediation`) — політика +кабінету, і вона відкрита. Видалення вбудованого правила — теж +`400 invalid`: вимкнене правило лишається видимим у звіті, видалене — +робить кабінет схожим на той, де вимога виконана. + +**Чому зразок перевіряють окремою ручкою.** Регулярний вираз, який +компілюється й не збігається НІКОЛИ, дає «порушень немає» — тобто +виглядає бездоганно й не перевіряє нічого; за результатом прогону його +не відрізнити від справного правила. `POST .../probe` віддає РЯДКИ, на +які зразок наліг, із номерами: + +```jsonc +{ + "device_name": "core-sw-01", + "config_type": "running", + "collected_at": "2026-08-28T06:00:00Z", + "lines": 412, + "no_config": false, // конфігу цього типу немає — це НЕ «нічого не знайшов» + "in_selector": true, // хост підпадає під склад правила + "matches": [ { "line_number": 11, "line": "transport input telnet ssh" } ], + "truncated": false, // збігів більше за стелю (200) + "passed": false // той самий висновок, що зробить прогін +} +``` + +**Зразок не може містити переносу рядка.** Перевірка йде порядково, тож +зразок із `\n` або `\r` не збігся б НІКОЛИ — а для `must_not_contain` і +`regex_absent` це вічне тихе «пройдено». Відмова — `400 invalid`. + +Збереження правила стирає його результати ЛИШЕ тоді, коли змінився +предмет перевірки (вид, зразок, тип конфігу, селектор). Вимкнення, +перейменування, інша серйозність чи порада картину прогону не чіпають. +Селектор порівнюється за ЗНАЧЕННЯМ і зберігається в канонічній формі +(порожні виміри не пишуться): `{}`, `{"vendors":[]}` і селектор із +`null` у всіх ключах — це один і той самий «до всіх», і правка правила +через них результатів не стирає. + +**Що вважається чинною знахідкою.** `/results` і звіт віддають лише +результати УВІМКНЕНИХ правил і лише по хостах, які не видалено. Вимкнули +правило — його старі знахідки перестають рахуватись у «Порушень N» +(самé правило лишається у звіті поіменно як вимкнене, і рядки в базі +теж лишаються: увімкніть і прожену́ть — картина повернеться). Прогін +додатково прибирає результати по хостах, які випали із селектора +правила: `/run` — єдина мить, коли повний склад чинних питань відомий. + +**Лічильники `failed`/`passed`** у `/rules` рахуються з тих самих рядків, +які людина отримує в `/results` — тобто після відсіву за видимістю. Два +числа не можуть розійтись: це одне число. Перелік вендорів у +`/probe-devices` теж обмежений видимістю — це агрегат про склад парку. + +**CSV.** Клітинка, що починається з `=`, `+`, `-`, `@`, таба чи CR, +екранується апострофом: у звіт їде сирий рядок конфігу пристрою, а Excel +прочитав би його як формулу й виконав на машині аудитора. Числа +(`-5`, `+12`) не чіпаються. Останній рядок файла — `КІНЕЦЬ ЗВІТУ` з +кількістю порушень: відповідь іде потоком уже після `200 OK`, тож +обірваний файл інакше не відрізнити від повного. + +**Звіт** несе дату, автора, час останньої перевірки, зведення за +серйозністю — і СКЛАД ПРАВИЛ на момент звіту зі зразками й походженням. +Без складу правил два роздруки з різними числами нічим пояснити: «18 +порушень» і «4» — це або робота інженерів, або хтось вимкнув +чотирнадцять правил. Окремо позначено правила, які не дали жодної +перевірки (`rules_never_run`) і обмежену видимість автора +(`partial_scope`). PDF немає: базові шрифти PDF не мають кирилиці, і +звіт без вкладеного шрифту вийшов би тихо зіпсованим. + ### `POST /api/v1/auth/login` — вхід ```jsonc diff --git a/server/internal/httpapi/compliance.go b/server/internal/httpapi/compliance.go index 8121a0f..298bd1e 100644 --- a/server/internal/httpapi/compliance.go +++ b/server/internal/httpapi/compliance.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strings" + "time" "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/store" @@ -25,7 +26,49 @@ func (s *Server) handleListComplianceRules(w http.ResponseWriter, r *http.Reques s.writeStoreError(w, "правила відповідності", err) return } - writeJSON(w, http.StatusOK, map[string]any{"rules": rules}) + // Лічильники «стільки з стількох не пройшли» рахуються ТУТ, із тих + // самих рядків, які людина побачить у «Знахідках», і після відсіву + // за видимістю. + // + // ПРИЧИНА: раніше їх давали два count(*) у запиті правил — по всіх + // хостах кабінету, без Scope. Інженер філії читав у списку «5 з 12 + // не пройшли», відкривав знахідки й бачив дві. Гірше за розбіжність + // те, що агрегат сам по собі розповідав про хости, яких людині + // бачити не належить. + // + // НАСЛІДОК: два числа не можуть розійтись у принципі — це одне + // число, пораховане один раз. + results, err := s.visibleComplianceResults(r, p, false) + if err != nil { + s.writeStoreError(w, "результати перевірки", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "rules": store.ApplyComplianceCounts(rules, results), + }) +} + +// visibleComplianceResults — знахідки, з яких прибрано хости поза +// видимістю людини. +// +// Одним місцем на всі три ручки (список правил, знахідки, звіт) +// навмисно: три копії цього циклу розійшлися б на першій правці, і +// розійшлись би тихо — кожне з чисел окремо виглядає правдоподібно. +func (s *Server) visibleComplianceResults(r *http.Request, p *Principal, onlyFailed bool) ([]store.ComplianceResult, error) { + res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed) + if err != nil { + return nil, err + } + // Хости поза видимістю людини не показуємо: перевірка конфігів не + // має бути обхідним шляхом до інвентарю чужої філії. + scope := p.Scope() + out := make([]store.ComplianceResult, 0, len(res)) + for _, x := range res { + if scope.CanRead(x.DeviceID) { + out = append(out, x) + } + } + return out, nil } func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request, p *Principal) { @@ -77,21 +120,11 @@ func (s *Server) handleListComplianceResults(w http.ResponseWriter, r *http.Requ return } onlyFailed := r.URL.Query().Get("failed") == "1" - res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed) + out, err := s.visibleComplianceResults(r, p, onlyFailed) if err != nil { s.writeStoreError(w, "результати перевірки", err) return } - - // Хости поза видимістю людини не показуємо: перевірка конфігів не - // має бути обхідним шляхом до інвентарю чужої філії. - scope := p.Scope() - out := make([]store.ComplianceResult, 0, len(res)) - for _, x := range res { - if scope.CanRead(x.DeviceID) { - out = append(out, x) - } - } writeJSON(w, http.StatusOK, map[string]any{"results": out}) } @@ -129,3 +162,179 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p * writeJSON(w, http.StatusOK, stat) } + +// --------------------------------------------------------------------- +// Перевірка зразка на справжньому конфізі +// --------------------------------------------------------------------- +// +// Окремі ручки, а не поле у відповіді збереження: зразок перевіряють ДО +// збереження й багато разів поспіль — по разу на кожну правку. Змусити +// зберігати правило, щоб побачити наслідок, означало б не лише +// застосувати неперевірений зразок до всього парку, а й СТЕРТИ картину +// попереднього прогону (SaveComplianceRule чистить результати правила). +// Тобто ціна однієї спроби «а якщо так?» була б у втраченій відповіді на +// питання «а як зараз». +// +// Взірець — POST /api/v1/templates/interface-preview: та сама думка про +// зразок, застосований наосліп. + +// handleComplianceProbeDevices — на чому можна перевірити зразок. +func (s *Server) handleComplianceProbeDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + configType := r.URL.Query().Get("config_type") + list, err := s.store.ListComplianceProbeDevices(r.Context(), p.TenantID, configType) + if err != nil { + s.writeStoreError(w, "хости з конфігами", err) + return + } + + // Хости поза видимістю прибираємо тут, а не в SQL: правило доступу + // живе в Scope, і другий його примірник у запиті розійшовся б із + // першим на найближчій правці. + scope := p.Scope() + out := make([]store.ComplianceProbeDevice, 0, len(list)) + for _, d := range list { + if scope.CanRead(d.ID) { + out = append(out, d) + } + } + + // Вендори тією ж ручкою: селектор вендорів і вибір хоста для + // перевірки — одне питання «що в мене взагалі стоїть», і зібрані + // двома запитами в різні секунди відповіді суперечили б одна одній. + // Scope їде в запит: перелік вендорів — агрегат про склад парку, і + // зібраний по всьому кабінету він розповів би інженерові філії про + // залізо, якого йому бачити не належить. + vendors, err := s.store.ListComplianceVendors(r.Context(), p.TenantID, scope) + if err != nil { + s.writeStoreError(w, "вендори", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out, "vendors": vendors}) +} + +// handleComplianceProbe проганяє незбережений зразок по одному хосту. +func (s *Server) handleComplianceProbe(w http.ResponseWriter, r *http.Request, p *Principal) { + // ncm:read, а не ncm:write. Перевірка нічого не змінює, і вимагати + // на неї право запису означало б, що людина без такого права бачить + // у звіті зразки правил і не має способу зрозуміти, що вони роблять. + if !requirePerm(w, p, "ncm:read") { + return + } + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування — тіла конфігів не прочитати") + return + } + + var in struct { + DeviceID string `json:"device_id"` + Rule store.ComplianceRule `json:"rule"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту") + return + } + if in.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "не вказано хост для перевірки") + return + } + if !p.Scope().CanRead(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + if in.Rule.Severity == "" { + in.Rule.Severity = "medium" + } + if in.Rule.ConfigType == "" { + in.Rule.ConfigType = "running" + } + + // Помилку в зразку віддаємо як 400 з текстом RE2: людина зараз + // дивиться на поле, у якому її зробила, і «внутрішня помилка» + // відправила б її шукати проблему деінде. + res, err := s.store.ProbeComplianceRule(r.Context(), p.TenantID, in.DeviceID, in.Rule, s.keyring) + if err != nil { + s.writeStoreError(w, "перевірка зразка правила", err) + return + } + writeJSON(w, http.StatusOK, res) +} + +// --------------------------------------------------------------------- +// Звіт +// --------------------------------------------------------------------- + +// complianceReport збирає звіт із того, що видно ЦІЙ людині. +// +// Спільний для JSON і CSV навмисно: два описи одного звіту розійшлися б +// на першій правці, і сторінка почала б показувати не те, що лежить у +// вивантаженому файлі. А звіряють їх саме так — файл проти екрана. +func (s *Server) complianceReport(r *http.Request, p *Principal) (store.ComplianceReport, error) { + rules, err := s.store.ListComplianceRules(r.Context(), p.TenantID) + if err != nil { + return store.ComplianceReport{}, err + } + // Усі результати, і пройдені теж: «18 порушень» без знаменника не + // означає нічого — це 18 із 28 чи 18 із 2800. + visible, err := s.visibleComplianceResults(r, p, false) + if err != nil { + return store.ComplianceReport{}, err + } + scope := p.Scope() + + who := p.Username + if who == "" { + who = p.TokenName + } + // Обмежена видимість їде у звіт полем, а не примітками в коді. + // Інженер філії вивантажить правду про свої хости — і документ, який + // виглядає як правда про всю мережу. + return store.ComplianceReportOf(time.Now(), who, !scope.Unrestricted, rules, visible), nil +} + +func (s *Server) handleComplianceReport(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + rep, err := s.complianceReport(r, p) + if err != nil { + s.writeStoreError(w, "звіт відповідності", err) + return + } + writeJSON(w, http.StatusOK, rep) +} + +// handleComplianceReportCSV — той самий звіт вивантаженням. +// +// Окремий обробник, а не параметр формату в попередньому: відповідь тут +// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт +// має вгадувати, як її читати. Квиток завантаження (0038) не потрібен: +// звіт — це десятки кілобайтів, а не десятки мегабайтів. +// +// PDF тут немає, і це рішення, а не недоробка. Пояснення — у +// web/src/pages/CompliancePage.tsx поруч із кнопкою, бо читає його той, +// хто цю кнопку шукає. +func (s *Server) handleComplianceReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + rep, err := s.complianceReport(r, p) + if err != nil { + s.writeStoreError(w, "звіт відповідності", err) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", + `attachment; filename="`+store.ComplianceReportFileName(rep)+`"`) + // Кешувати не можна: за тим самим шляхом наступного разу лежатиме + // звіт про інший стан мережі, а розрізнити їх кешу нема чим. + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if err := store.ComplianceReportCSV(w, rep); err != nil { + // Відповідь уже пішла — статус не змінити. Лишається журнал. + s.log.Error("вивантаження звіту відповідності", "err", err) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 9459beb..a44a805 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -217,6 +217,15 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule)) mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults)) mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance)) + // Перевірка зразка на справжньому конфізі — до збереження правила. + // Без неї редактор правил є генератором тихої брехні: зразок, який + // не збігається ніколи, дає «порушень немає». + mux.Handle("GET /api/v1/ncm/compliance/probe-devices", s.authenticated(s.handleComplianceProbeDevices)) + mux.Handle("POST /api/v1/ncm/compliance/probe", s.authenticated(s.handleComplianceProbe)) + // Звіт. Два шляхи, бо два типи вмісту: .csv віддає файл, і плутати + // його з JSON в одному шляху означає, що клієнт має вгадувати. + mux.Handle("GET /api/v1/ncm/compliance/report", s.authenticated(s.handleComplianceReport)) + mux.Handle("GET /api/v1/ncm/compliance/report.csv", s.authenticated(s.handleComplianceReportCSV)) // Масове виконання команд. Перегляд переліку хостів — окремий крок // і окремий ендпоїнт: підтвердження без точного переліку не diff --git a/server/internal/store/csv_safe.go b/server/internal/store/csv_safe.go new file mode 100644 index 0000000..668b037 --- /dev/null +++ b/server/internal/store/csv_safe.go @@ -0,0 +1,97 @@ +package store + +import ( + "encoding/csv" + "strconv" +) + +// Одне місце, де вивантаження захищається від ін'єкції формул. +// +// ПРИЧИНА. Excel і LibreOffice розбирають клітинку, що починається з +// `=`, `+`, `-`, `@`, а також з табуляції чи CR (їх вони спершу +// відкидають, а тоді дивляться на наступний символ), як ФОРМУЛУ, а не +// як текст. Формула виду `=cmd|'/C calc'!A0` виконується при відкритті +// файла — і виконується на машині того, хто його відкрив. +// +// У наших звітах у клітинки їде рівно те, що зробило б таку атаку +// дешевою: `v.Line` — це СИРИЙ рядок конфігу пристрою, тобто текст, який +// у мережу міг покласти хто завгодно з доступом до заліза; поруч — +// зразок правила, порада, назва правила й імена хостів. Файл за задумом +// відкриває керівник або аудитор, тобто людина з правами й без звички +// підозрювати таблицю. +// +// НАСЛІДОК, якби захисту не було: звіт про відповідність вимогам стає +// способом виконати код на робочій станції аудитора — тобто рівно +// протилежним до того, чим він є. +// +// ЧОМУ ОДНЕ МІСЦЕ, А НЕ ТРИ. Письменників CSV у репозиторії три: +// ComplianceReportCSV, SLAReportCSV і writeCSVReport (звіт NCM). Захист, +// переписаний у кожному, гарантовано забудуть у четвертому — і забудуть +// мовчки, бо зіпсований файл виглядає як звичайний. Тому всі троє пишуть +// не в `*csv.Writer`, а в safeCSV нижче. +// +// ЧОГО ЦЕ НЕ РОБИТЬ. Це не санітизація вмісту: рядок конфігу лишається +// собою, лише перестає бути формулою. І це не заміна перевірці на боці +// того, хто файл відкриває, — це найдешевший шар, який ми можемо +// покласти самі. + +// csvFormulaLead — символи, з яких табличний процесор починає читати +// формулу. `\t` і `\r` тут не для краси: обидва відкидаються перед +// розбором, тож `\t=1+1` — це та сама формула, лише в обгортці. +func csvFormulaLead(b byte) bool { + switch b { + case '=', '+', '-', '@', '\t', '\r': + return true + } + return false +} + +// csvSafeCell — клітинка, яку таблиця прочитає як текст. +// +// Апостроф попереду — домовленість самого Excel: він означає «далі +// текст» і в самій клітинці не показується. +// +// ВИНЯТОК ДЛЯ ЧИСЕЛ. `-5` і `-0.75` починаються з небезпечного символу й +// формулою не є. Екранувати їх означало б перетворити число на текст — +// і стовпчик, який читач звіту складає, перестав би складатися. Тому +// перед екрануванням питаємо, чи це взагалі число: якщо так, лишаємо як +// є. Помилитись у цей бік безпечно — `=1+1` числом не є, а +// `-2+3+cmd|'/C calc'!A0` не розбирається як число й буде екранований. +func csvSafeCell(s string) string { + if s == "" || !csvFormulaLead(s[0]) { + return s + } + if _, err := strconv.ParseFloat(s, 64); err == nil { + return s + } + return "'" + s +} + +// safeCSV — тонка обгортка над encoding/csv, яка не вміє писати +// неекрановану клітинку. +// +// Обгортка бере готовий *csv.Writer, а не створює його сама: у трьох +// наших звітів різні домовленості (роздільник, CRLF), і зводити їх до +// одного набору означало б непомітно змінити формат двох із них. +type safeCSV struct{ cw *csv.Writer } + +func newSafeCSV(cw *csv.Writer) *safeCSV { return &safeCSV{cw: cw} } + +// Write пише рядок, екрануючи кожну клітинку. +// +// Порожній рядок (nil) проходить як є: у наших звітах він розділяє +// розділи, і Excel його показує, а імпортери пропускають. +func (s *safeCSV) Write(row []string) error { + if row == nil { + return s.cw.Write(nil) + } + out := make([]string, len(row)) + for i, v := range row { + out[i] = csvSafeCell(v) + } + return s.cw.Write(out) +} + +func (s *safeCSV) Flush() { s.cw.Flush() } + +func (s *safeCSV) Error() error { return s.cw.Error() } diff --git a/server/internal/store/ncm_compliance.go b/server/internal/store/ncm_compliance.go index 54a4e37..4e7ca80 100644 --- a/server/internal/store/ncm_compliance.go +++ b/server/internal/store/ncm_compliance.go @@ -36,6 +36,19 @@ type ComplianceRule struct { // попереджає, просто відповідає про інший документ. 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"` @@ -63,7 +76,260 @@ var complianceKinds = map[string]bool{ "regex_match": true, "regex_absent": true, } -// ListComplianceRules — правила разом із підсумком останньої перевірки. +// Серйозності — рівно значення 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 { @@ -71,10 +337,7 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com 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, - (SELECT count(*) FROM ncm.compliance_results x - WHERE x.rule_id = r.id AND NOT x.passed), - (SELECT count(*) FROM ncm.compliance_results x - WHERE x.rule_id = r.id AND x.passed) + COALESCE(r.builtin_key,'') FROM ncm.compliance_rules r WHERE r.tenant_id = $1 ORDER BY r.name @@ -89,10 +352,10 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com 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.Failed, &r.Passed); err != nil { + &r.BuiltinKey); err != nil { return err } - if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { + if r.Selector, err = parseSelector(sel); err != nil { return fmt.Errorf("правило %s: selector: %w", r.Name, err) } out = append(out, r) @@ -102,24 +365,53 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com return out, err } -// SaveComplianceRule створює або оновлює правило. -func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r ComplianceRule) (string, error) { - if !complianceKinds[r.Kind] { - return "", fmt.Errorf("%w: невідомий вид правила %q", ErrInvalid, r.Kind) - } - if strings.TrimSpace(r.Pattern) == "" { - 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) +// 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 +} - sel, err := json.Marshal(r.Selector) +// 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 } @@ -138,6 +430,42 @@ func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r Compl 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, @@ -152,17 +480,54 @@ func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r Compl if tag.RowsAffected() == 0 { return ErrNotFound } - // Зразок міг змінитись — старі результати більше ні про що не - // свідчать, і лишати їх означає показувати відповідь на питання, - // якого вже ніхто не ставив. - _, err = tx.Exec(ctx, `DELETE FROM ncm.compliance_results WHERE rule_id = $1`, r.ID) - return err + // Результати стираємо, лише якщо змінився ПРЕДМЕТ перевірки: + // старі відповіді на питання, якого вже ніхто не ставить, гірші + // за їх відсутність. Вимкнення, перейменування, інша серйозність + // чи інша порада питання не змінюють — і платити за них стертою + // картиною мережі немає за що (див. 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() довкладає +// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось +// би при наступному накаті довідника — уже ввімкненим і без сліду, що +// його свідомо прибирали. func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var builtin string + err := tx.QueryRow(ctx, ` + SELECT COALESCE(builtin_key,'') FROM ncm.compliance_rules + WHERE id = $1 AND tenant_id = $2 + FOR UPDATE + `, id, tenantID).Scan(&builtin) + 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 { @@ -176,6 +541,27 @@ func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) e } // 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 { @@ -189,6 +575,8 @@ func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, only 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), @@ -273,6 +661,21 @@ func (s *Store) RunCompliance(ctx context.Context, tenantID string, ring *crypto 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) @@ -378,6 +781,24 @@ func (s *Store) complianceTargets(ctx context.Context, tenantID string, sel Sele 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" diff --git a/server/internal/store/ncm_compliance_edit_test.go b/server/internal/store/ncm_compliance_edit_test.go new file mode 100644 index 0000000..b1c1ca7 --- /dev/null +++ b/server/internal/store/ncm_compliance_edit_test.go @@ -0,0 +1,1244 @@ +package store + +import ( + "encoding/json" + "errors" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// Редагування правил відповідності та звіт. +// +// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ — і чого він НЕ доводить, написано в кінці, у +// TestNothingHereProvesTheWholeChain. Коротко головне: +// +// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що +// вона перевіряє. Тест ізоляції RLS був правильний і зелений — і +// пропустив зламаний вхід, бо перевіряв «чи не видно чужого», коли +// зламалось «чи видно своє». +// +// Тут та сама пастка стоїть в іншому місці. Природний тест правила +// відповідності — «зразок збігся, порушення знайшлось». Він зелений і +// нічого не вартий сам по собі: правило зі зразком, що НЕ збігається +// ніколи, теж дає зелений екран — «порушень немає». Тому кожна +// перевірка нижче йде парою: зразок, що ловить, і зразок, що не ловить +// НІКОЛИ, — і другий мусить лишити слід, який видно. + +// --------------------------------------------------------------------- +// Взірець конфігу +// --------------------------------------------------------------------- + +// Уривок справжнього вигляду `show running-config` — рівно те, на чому +// знайшлись 18 порушень першого прогону на мережі клієнта: типові +// community, telnet на керуванні, пароль зворотним шифром, доступ до +// керування без списку. +const sampleConfig = `! +hostname core-sw-01 +! +enable secret 5 $1$abcd$0123456789ABCDEFGH +username admin password 7 070C285F4D06 +! +snmp-server community public RO +snmp-server community private RW +! +line vty 0 4 + transport input telnet ssh + login local +! +ip http server +! +end` + +// --------------------------------------------------------------------- +// 1. Висновок правила: пара «ловить» / «не ловить ніколи» +// --------------------------------------------------------------------- + +func TestCheckComplianceVerdict(t *testing.T) { + cases := []struct { + name string + kind string + // pattern — зразок; matches каже, чи він узагалі щось знаходить + // у sampleConfig. Це поле — не дублювання очікування, а + // відповідь на питання «який саме випадок ми зараз перевіряємо»: + // половина рядків нижче існує лише заради matches=false. + pattern string + matches bool + // wantPassed — вислід, який побачить прогін. + wantPassed bool + // wantLine — чи буде що показати людині. Для «має бути» знахідка + // порожня навмисно: проблема саме у відсутності рядка. + wantLine string + wantNum int + }{ + { + name: "не має містити: telnet знайдено", + kind: "must_not_contain", + pattern: "transport input telnet", + matches: true, + wantPassed: false, + wantLine: "transport input telnet ssh", + wantNum: 11, + }, + { + name: "не має містити: зразок не збігається НІКОЛИ", + // Друкарська помилка на один символ: `imput` замість `input`. + // Правило компілюється, зберігається, проганяється — і + // повідомляє «порушень немає» на конфізі, де telnet + // увімкнений. Оце й є тиха брехня, заради якої існує + // перевірка зразка на живому конфізі. + kind: "must_not_contain", + pattern: "transport imput telnet", + matches: false, + wantPassed: true, + wantLine: "", + wantNum: 0, + }, + { + name: "має містити: рядок є", + kind: "must_contain", + pattern: "enable secret", + matches: true, + wantPassed: true, + wantLine: "enable secret 5 $1$abcd$0123456789ABCDEFGH", + wantNum: 4, + }, + { + name: "має містити: зразок не збігається НІКОЛИ", + // Тут порожній зразок бʼє в інший бік: правило «має бути» + // з непотрапляючим зразком показує ПОРУШЕННЯ на кожному + // хості. Помилка гучна — її помітять того ж дня. Саме + // тому вона й безпечніша за попередню. + kind: "must_contain", + pattern: "ntp server 10.0.0.1", + matches: false, + wantPassed: false, + wantLine: "", + wantNum: 0, + }, + { + name: "збіг за виразом: знайдено", + kind: "regex_match", + pattern: `^\s*login local\s*$`, + matches: true, + wantPassed: true, + wantLine: "login local", + wantNum: 12, + }, + { + name: "немає збігу: пароль зворотним шифром", + kind: "regex_absent", + // Тип 7 у Cisco — не шифр, а кодування: розкручується в один + // рядок будь-яким онлайн-декодером. + pattern: `password\s+7\s+[0-9A-Fa-f]+`, + matches: true, + wantPassed: false, + wantLine: "username admin password 7 070C285F4D06", + wantNum: 5, + }, + { + name: "немає збігу: вираз, який не збіжиться ніколи", + // `\bpassword\b` після `^` без пробілів: у конфізі рядок + // починається з `username`, тож зразок не наліг НІ НА ЩО. + // І правило показує «пройдено». + kind: "regex_absent", + pattern: `^password\s+7\s`, + matches: false, + wantPassed: true, + wantLine: "", + wantNum: 0, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := ComplianceRule{ + Name: c.name, Kind: c.kind, Pattern: c.pattern, + Severity: "high", ConfigType: "running", Enabled: true, + } + if err := validateComplianceRule(r); err != nil { + t.Fatalf("правило мало бути коректним: %v", err) + } + + var re *regexp.Regexp + if strings.HasPrefix(c.kind, "regex") { + re = regexp.MustCompile(c.pattern) + } + + passed, line, num := checkCompliance(r, re, sampleConfig) + if passed != c.wantPassed { + t.Errorf("вислід = %v, очікували %v", passed, c.wantPassed) + } + if line != c.wantLine { + t.Errorf("рядок = %q, очікували %q", line, c.wantLine) + } + if num != c.wantNum { + t.Errorf("номер рядка = %d, очікували %d", num, c.wantNum) + } + + // ГОЛОВНЕ ТУТ. Вислід сам по собі не відрізняє «зразок + // перевірив і не знайшов» від «зразок не має що перевіряти». + // Відрізняє перелік збігів, який показує форма. + got, _ := probeMatches(r, re, sampleConfig) + if c.matches && len(got) == 0 { + t.Errorf("зразок %q не наліг у формі на жоден рядок — людина побачила б "+ + "порожній перелік і мусила б здогадуватись", c.pattern) + } + if !c.matches && len(got) != 0 { + t.Errorf("зразок %q мав не знайти нічого, а знайшов %d рядків", c.pattern, len(got)) + } + }) + } +} + +// TestProbeShowsNeverMatchingPattern — та сама пара, але поставлена як +// питання до ФОРМИ, а не до прогону. +// +// Прогін на обох цих правилах дає однакову відповідь: «порушень немає». +// Якби форма показувала лише її, редактор правил був би генератором +// тихої брехні. Тест фіксує, що форма показує РІЗНЕ. +func TestProbeShowsNeverMatchingPattern(t *testing.T) { + good := ComplianceRule{ + Kind: "must_not_contain", Pattern: "transport input telnet", + Severity: "critical", ConfigType: "running", + } + typo := good + typo.Pattern = "transport imput telnet" // одна літера + + goodPassed, _, _ := checkCompliance(good, nil, sampleConfig) + typoPassed, _, _ := checkCompliance(typo, nil, sampleConfig) + if goodPassed { + t.Fatal("правильне правило мало знайти telnet") + } + if !typoPassed { + t.Fatal("правило з опискою мало показати «пройдено» — саме в цьому й біда") + } + + goodHits, _ := probeMatches(good, nil, sampleConfig) + typoHits, _ := probeMatches(typo, nil, sampleConfig) + if len(goodHits) == 0 { + t.Error("справний зразок мусить дати рядки у формі") + } + if len(typoHits) != 0 { + t.Error("зразок з опискою мусить дати ПОРОЖНІЙ перелік — це і є видимий слід помилки") + } + if goodHits[0].LineNumber != 11 { + t.Errorf("номер рядка = %d, очікували 11: без номера знахідка не дія, а натяк", + goodHits[0].LineNumber) + } +} + +// TestProbeMatchesAllLines — форма показує ВСІ збіги, а не перший. +// +// Прогін зупиняється на першому, і для висновку цього досить. Але +// людина у формі відповідає на інше питання: «мій зразок ловить те, що +// я думаю». Один рядок на нього не відповідає — обидва community з +// конфігу мають бути видимі. +func TestProbeMatchesAllLines(t *testing.T) { + r := ComplianceRule{ + Kind: "regex_absent", Pattern: `snmp-server community (public|private)`, + Severity: "critical", ConfigType: "running", + } + re := regexp.MustCompile(r.Pattern) + + hits, truncated := probeMatches(r, re, sampleConfig) + if truncated { + t.Error("двох рядків не буває забагато") + } + if len(hits) != 2 { + t.Fatalf("збігів %d, очікували 2 (public і private)", len(hits)) + } + if hits[0].LineNumber != 7 || hits[1].LineNumber != 8 { + t.Errorf("номери рядків = %d, %d; очікували 7 і 8", + hits[0].LineNumber, hits[1].LineNumber) + } + + // Стеля видима: обрізаний перелік, поданий як повний, — це та сама + // брехня, лише в інший бік. + big := strings.Repeat("snmp-server community public RO\n", complianceProbeMax+10) + hits, truncated = probeMatches(r, re, big) + if !truncated { + t.Error("перелік понад стелю мусить бути позначений як обрізаний") + } + if len(hits) != complianceProbeMax { + t.Errorf("віддано %d рядків, стеля %d", len(hits), complianceProbeMax) + } +} + +// --------------------------------------------------------------------- +// 2. Валідація: що сервер не дасть зберегти +// --------------------------------------------------------------------- + +func TestValidateComplianceRule(t *testing.T) { + base := ComplianceRule{ + Name: "Telnet вимкнено", Kind: "regex_absent", + Pattern: `transport input.*telnet`, Severity: "critical", + ConfigType: "running", Enabled: true, + } + if err := validateComplianceRule(base); err != nil { + t.Fatalf("коректне правило відхилено: %v", err) + } + + cases := []struct { + name string + mut func(r *ComplianceRule) + // why — що саме зламалось би в бойовій мережі, якби це + // збереглось. Не оздоба: без цього рядка наступний читач не + // зрозуміє, чому саме тут потрібна відмова, а не умовчання. + why string + }{ + { + name: "невідомий вид", + mut: func(r *ComplianceRule) { r.Kind = "jsonpath" }, + why: "jsonpath є в ncm.rule_kind, але checkCompliance його не вміє — правило мовчало б", + }, + { + name: "невідома серйозність", + mut: func(r *ComplianceRule) { r.Severity = "urgent" }, + why: "приведення до ncm.rule_severity дало б 22P02 і 500 «внутрішня помилка»", + }, + { + name: "описка в типі конфігу", + mut: func(r *ComplianceRule) { r.ConfigType = "runnning" }, + why: "config_type — звичайний text: описка зберігається мовчки, жоден хост " + + "не підпадає, усі пари йдуть у пропущені, правило показує «порушень немає»", + }, + { + name: "порожній зразок", + mut: func(r *ComplianceRule) { r.Pattern = " " }, + why: "правило без зразка нічого не перевіряє", + }, + { + name: "вираз не компілюється", + mut: func(r *ComplianceRule) { r.Pattern = `transport input (telnet` }, + why: "інакше про друкарську помилку людина дізнається не з форми, а з тиші правила", + }, + { + name: "перенос рядка у зразку «не має містити»", + mut: func(r *ComplianceRule) { + r.Kind = "must_not_contain" + r.Pattern = "line vty 0 4\n transport input telnet" + }, + why: "перевірка йде порядково — зразок із переносом не збігся б НІКОЛИ, " + + "а для «не має містити» це вічне тихе «пройдено»", + }, + { + name: "перенос рядка у зразку «має містити»", + mut: func(r *ComplianceRule) { + r.Kind = "must_contain" + r.Pattern = "ntp server 10.0.0.1\n" + }, + why: "той самий зразок, що не збіжиться ніколи; тут він сипле порушеннями на " + + "кожному хості — помилка гучна, але однаково неправдива", + }, + { + name: "перенос рядка у регулярному виразі", + mut: func(r *ComplianceRule) { r.Pattern = "transport input\n.*telnet" }, + why: "вираз компілюється, але жоден поданий рядок переносу вже не містить", + }, + { + name: "повернення каретки у зразку", + mut: func(r *ComplianceRule) { r.Pattern = "transport input telnet\r" }, + why: "конфіги приходять і з CRLF: strings.Split по \\n лишає \\r у кінці рядка, " + + "тож зразок із \\r збігається то там, то там — залежно від пристрою", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := base + c.mut(&r) + err := validateComplianceRule(r) + if err == nil { + t.Fatalf("збереглося б, а не мало: %s", c.why) + } + if !errors.Is(err, ErrInvalid) { + t.Errorf("помилка мусить бути ErrInvalid (400, а не 500): %v", err) + } + }) + } +} + +// TestValidateAcceptsEveryConfigType — інший бік попередньої перевірки. +// +// Без нього «жоден тип не проходить» був би так само зеленим: перевірка +// відмов доводить лише те, що відмови бувають. Пара обовʼязкова — це +// рівно урок RLS: «чи не видно чужого» без «чи видно своє» не доводить +// нічого. +func TestValidateAcceptsEveryConfigType(t *testing.T) { + for _, ct := range []string{"running", "startup", "files"} { + r := ComplianceRule{ + Name: "x", Kind: "must_contain", Pattern: "ntp", + Severity: "low", ConfigType: ct, + } + if err := validateComplianceRule(r); err != nil { + t.Errorf("тип конфігу %q мусить прийматись: %v", ct, err) + } + } + for _, sev := range complianceSeverityOrder { + r := ComplianceRule{ + Name: "x", Kind: "must_contain", Pattern: "ntp", + Severity: sev, ConfigType: "running", + } + if err := validateComplianceRule(r); err != nil { + t.Errorf("серйозність %q мусить прийматись: %v", sev, err) + } + } +} + +// --------------------------------------------------------------------- +// 3. Замок на вбудованих правилах +// --------------------------------------------------------------------- + +func TestBuiltinImmutable(t *testing.T) { + cur := complianceRuleHead{ + BuiltinKey: "snmp-default-community", + Name: "SNMP: типові community", + Kind: "regex_absent", + Pattern: `snmp-server community\s+(public|private)`, + ConfigType: "running", + } + same := ComplianceRule{ + Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern, ConfigType: cur.ConfigType, + } + + t.Run("те саме питання — правка проходить", func(t *testing.T) { + // Політика кабінету змінюється вільно: це і є те, заради чого + // 0071 завела копію правила на кабінет. + r := same + r.Enabled = false + r.Severity = "low" + r.Remediation = "прибрати community з конфігу" + r.Selector = Selector{Vendors: []string{"Cisco"}} + if err := checkBuiltinImmutable(cur, r); err != nil { + t.Errorf("вимкнути, звузити селектор і знизити серйозність мусить бути можна: %v", err) + } + }) + + locked := []struct { + field string + mut func(r *ComplianceRule) + }{ + {"зразок", func(r *ComplianceRule) { r.Pattern = "community" }}, + {"назва", func(r *ComplianceRule) { r.Name = "Наше правило" }}, + {"вид умови", func(r *ComplianceRule) { r.Kind = "must_not_contain" }}, + {"тип конфігу", func(r *ComplianceRule) { r.ConfigType = "files" }}, + } + for _, c := range locked { + t.Run("замкнено: "+c.field, func(t *testing.T) { + r := same + c.mut(&r) + err := checkBuiltinImmutable(cur, r) + if err == nil { + t.Fatalf("%s вбудованого правила переписалось би, а значок «вбудоване» "+ + "лишився б — і звіт посилався б на довідник, якого вже не дотримується", + c.field) + } + if !errors.Is(err, ErrInvalid) { + t.Errorf("мусить бути ErrInvalid: %v", err) + } + if !strings.Contains(err.Error(), "копію") { + t.Errorf("відмова мусить називати вихід («зробіть копію»), а не лише заборону: %q", + err.Error()) + } + }) + } + + t.Run("своє правило не замкнене", func(t *testing.T) { + own := complianceRuleHead{Name: "Наше", Kind: "must_contain", + Pattern: "ntp server", ConfigType: "running"} + r := ComplianceRule{Name: "Інше", Kind: "regex_absent", + Pattern: "telnet", ConfigType: "files"} + if err := checkBuiltinImmutable(own, r); err != nil { + t.Errorf("правило без builtin_key правиться цілком: %v", err) + } + }) +} + +// TestComplianceSubjectChanged — що стирає картину прогону, а що ні. +// +// Це прямо про головну дію над вбудованим правилом — «вимкнути в +// себе». Якби вимкнення стирало результати, людина платила б за +// один клац усією картиною мережі по цьому правилу — і втрата +// була б тихою: на екрані просто стало б менше рядків. +func TestComplianceSubjectChanged(t *testing.T) { + cur := complianceRuleHead{ + BuiltinKey: "telnet-disabled", + Name: "Telnet вимкнено", + Kind: "regex_absent", + Pattern: `transport input.*telnet`, + ConfigType: "running", + } + same := ComplianceRule{ + Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern, + ConfigType: cur.ConfigType, Severity: "high", Enabled: true, + } + + keep := []struct { + name string + mut func(r *ComplianceRule) + }{ + {"вимкнення", func(r *ComplianceRule) { r.Enabled = false }}, + {"інша серйозність", func(r *ComplianceRule) { r.Severity = "low" }}, + {"інша порада", func(r *ComplianceRule) { r.Remediation = "transport input ssh" }}, + {"інший опис", func(r *ComplianceRule) { r.Description = "вимога безпеки" }}, + } + for _, c := range keep { + t.Run("зберігає: "+c.name, func(t *testing.T) { + r := same + c.mut(&r) + if complianceSubjectChanged(cur, r) { + t.Error("питання не змінилось — результати мають лишитись") + } + }) + } + + drop := []struct { + name string + mut func(r *ComplianceRule) + }{ + {"інший зразок", func(r *ComplianceRule) { r.Pattern = "telnet" }}, + {"інший вид", func(r *ComplianceRule) { r.Kind = "must_not_contain" }}, + {"інший тип конфігу", func(r *ComplianceRule) { r.ConfigType = "startup" }}, + } + for _, c := range drop { + t.Run("стирає: "+c.name, func(t *testing.T) { + r := same + c.mut(&r) + if !complianceSubjectChanged(cur, r) { + t.Error("правило питає вже інше — старі результати більше ні про що не свідчать") + } + }) + } + + t.Run("звужений селектор стирає", func(t *testing.T) { + c := cur + c.Selector = Selector{} // було «до всіх» + r := same + r.Selector = Selector{GroupIDs: []string{"g-branch"}} + if !complianceSubjectChanged(c, r) { + t.Error("після звуження селектора результати лишились би про хости, " + + "яких правило вже не стосується") + } + }) + + t.Run("той самий селектор в іншій формі запису НЕ стирає", func(t *testing.T) { + // Головний випадок цього файла: `{}` з міграції 0071 і порожній + // Selector із форми — той самий склад хостів. + c := cur + var err error + if c.Selector, err = parseSelector(`{}`); err != nil { + t.Fatal(err) + } + r := same + r.Enabled = false // єдина справжня зміна: «вимкнути в себе» + if complianceSubjectChanged(c, r) { + t.Error("вимкнення вбудованого правила стерло б картину всієї мережі по ньому") + } + }) +} + +// TestSelectorComparedByValueNotByForm — найдорожча знахідка рецензії, +// відтворена на самих ПРЕДСТАВЛЕННЯХ. +// +// Тест навмисно не про прапорець «селектор змінився», а про два тексти, +// які насправді зустрічаються: `{}` кладе ncm.builtin_compliance_apply +// (0071), а сім ключів зі значенням null дає json.Marshal(Selector) — +// у store.Selector немає жодного `omitempty`. +// +// Стара перевірка жила в SQL (`selector IS DISTINCT FROM $3::jsonb`) і +// була ПРАВИЛЬНОЮ по суті: jsonb порівнює за змістом, а не за формою. +// Але для jsonb `{}` і `{"device_ids":null,…}` — різні значення. Отже, +// ПЕРШЕ збереження будь-якого вбудованого правила — вимкнути, знизити +// серйозність, дописати пораду — вважалось зміною селектора й стирало +// ncm.compliance_results. Помилка одноразова на правило, тому непомітна; +// а стерта картина мережі виглядає точнісінько як «у вас усе добре». +func TestSelectorComparedByValueNotByForm(t *testing.T) { + // Спершу доводимо, що проблема справді існує, а не вигадана: два + // описи ПОРОЖНЬОГО селектора — різні тексти й різні значення jsonb. + rawGo, err := json.Marshal(Selector{}) + if err != nil { + t.Fatal(err) + } + const stored = `{}` + if string(rawGo) == stored { + t.Fatalf("у Selector з'явився omitempty — цей тест треба переписати; marshal дав %s", rawGo) + } + if !jsonbDistinct(t, string(rawGo), stored) { + t.Fatal("jsonb вважав би ці два описи однаковими — тоді помилки не було б") + } + + cases := []struct { + name string + // stored — як селектор лежить у базі. + stored string + next Selector + same bool + }{ + {"порожній із 0071 проти порожнього з форми", `{}`, Selector{}, true}, + {"сім null-ключів проти порожнього з форми", + `{"device_ids":null,"group_ids":null,"site_ids":null,"kinds":null,` + + `"template_ids":null,"vendors":null,"tags":null}`, Selector{}, true}, + {"порожній масив — це теж «до всіх»", `{"vendors":[]}`, Selector{}, true}, + {"колонка NULL", ``, Selector{}, true}, + {"вендори: той самий склад", `{"vendors":["Cisco"]}`, + Selector{Vendors: []string{"Cisco"}}, true}, + {"вендори: інший пробіл у тексті", `{"vendors": [ "Cisco" ]}`, + Selector{Vendors: []string{"Cisco"}}, true}, + {"вендори: 0071 із вендором проти форми з нулями в решті", + `{"vendors":["Cisco","Huawei"]}`, + Selector{Vendors: []string{"Cisco", "Huawei"}}, true}, + + // А ось це — справжні зміни складу хостів, і їх пропустити не + // можна: старі результати лишились би про хости, яких правило + // вже не стосується. + {"вендора додали", `{"vendors":["Cisco"]}`, + Selector{Vendors: []string{"Cisco", "Huawei"}}, false}, + {"звузили до групи", `{}`, + Selector{GroupIDs: []string{"g-1"}}, false}, + {"звузили до окремих хостів", `{}`, + Selector{DeviceIDs: []string{"d-1"}}, false}, + {"мітки з'явились", `{}`, + Selector{Tags: map[string]string{"env": "prod"}}, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cur, err := parseSelector(c.stored) + if err != nil { + t.Fatalf("розбір збереженого селектора: %v", err) + } + if got := selectorsEqual(cur, c.next); got != c.same { + t.Errorf("selectorsEqual = %v, очікували %v; порівнюватись має ЗНАЧЕННЯ, "+ + "а не форма запису — інакше правка правила стирає картину мережі", + got, c.same) + } + }) + } +} + +// jsonbDistinct — те саме, що `a IS DISTINCT FROM b` для двох jsonb. +// +// Потрібне рівно для одного: показати в тесті, що стара перевірка в SQL +// не була «текстовим порівнянням, яке легко полагодити пробілом». Вона +// порівнювала значення — просто значення були різні, бо описи були +// різні. +func jsonbDistinct(t *testing.T, a, b string) bool { + t.Helper() + var ma, mb map[string]any + if err := json.Unmarshal([]byte(a), &ma); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(b), &mb); err != nil { + t.Fatal(err) + } + return !reflect.DeepEqual(ma, mb) +} + +// TestSelectorJSONIsCanonical — те, що їде в базу, залежить лише від +// значення. +// +// ПРИЧИНА: selectorJSON — це не лише порівняння, а й те, що пишеться в +// колонку. Отже, база нормалізується сама, при кожному збереженні, і +// окрема міграція для цього не потрібна. Але тільки якщо форма стала. +func TestSelectorJSONIsCanonical(t *testing.T) { + empty, err := selectorJSON(Selector{}) + if err != nil { + t.Fatal(err) + } + if string(empty) != "{}" { + t.Errorf("порожній селектор пишеться як %s, а 0071 кладе {} — знову дві форми того самого", + empty) + } + + // Порожні виміри не потрапляють у запис узагалі: «жодної групи» і + // «поля немає» — одне твердження. + withEmpties, err := selectorJSON(Selector{ + Vendors: []string{"Cisco"}, + GroupIDs: []string{}, + Tags: map[string]string{}, + }) + if err != nil { + t.Fatal(err) + } + if string(withEmpties) != `{"vendors":["Cisco"]}` { + t.Errorf("канонічний запис = %s; порожні виміри мали зникнути", withEmpties) + } +} + +// TestApplyComplianceCounts — лічильники списку правил рахуються з тих +// самих рядків, що й «Знахідки». +// +// ПРИЧИНА. Раніше їх давали два count(*) у запиті правил — по ВСІХ +// хостах кабінету, без Scope й без відсіву видалених. Інженер філії +// читав «5 з 12 не пройшли», відкривав знахідки — там дві. Крім +// розбіжності, це ще й витік: агрегат розповідав про хости, яких людині +// бачити не належить. +func TestApplyComplianceCounts(t *testing.T) { + rules := []ComplianceRule{ + // Числа, «принесені з SQL по всьому кабінету»: саме їх треба + // перебити тим, що людина бачить. + {ID: "r-1", Name: "SNMP", Failed: 5, Passed: 7}, + {ID: "r-2", Name: "Telnet", Failed: 3, Passed: 0}, + } + visible := []ComplianceResult{ + {RuleID: "r-1", DeviceID: "d-1", Passed: false}, + {RuleID: "r-1", DeviceID: "d-2", Passed: false}, + {RuleID: "r-1", DeviceID: "d-3", Passed: true}, + } + + got := ApplyComplianceCounts(rules, visible) + if got[0].Failed != 2 || got[0].Passed != 1 { + t.Errorf("SNMP: %d не пройшли з %d; очікували 2 і 1 — рівно те, що у «Знахідках»", + got[0].Failed, got[0].Failed+got[0].Passed) + } + // Правило, від якого людині не видно жодного хоста, мусить показати + // нулі, а не чуже число. «3 не пройшли» без жодного рядка нижче — це + // повідомлення про чужий парк. + if got[1].Failed != 0 || got[1].Passed != 0 { + t.Errorf("Telnet: %d/%d; очікували нулі — видимих результатів немає", + got[1].Failed, got[1].Passed) + } +} + +// --------------------------------------------------------------------- +// 4. Звіт +// --------------------------------------------------------------------- + +func reportFixture() ([]ComplianceRule, []ComplianceResult) { + rules := []ComplianceRule{ + { + ID: "r-snmp", Name: "SNMP: типові community", Kind: "regex_absent", + Pattern: `community\s+(public|private)`, Severity: "critical", + ConfigType: "running", Enabled: true, BuiltinKey: "snmp-default-community", + Remediation: "no snmp-server community public", + }, + { + ID: "r-telnet", Name: "Telnet вимкнено", Kind: "regex_absent", + Pattern: `transport input.*telnet`, Severity: "high", + ConfigType: "running", Enabled: true, BuiltinKey: "telnet-disabled", + Remediation: "line vty 0 4 / transport input ssh", + }, + { + // Вимкнене правило: у прогоні його немає, результатів немає, + // і звіт мусить сказати про це вголос. + ID: "r-acl", Name: "Керування обмежене списком", Kind: "must_contain", + Pattern: "access-class", Severity: "high", + ConfigType: "running", Enabled: false, BuiltinKey: "mgmt-acl", + }, + { + // Своє правило, увімкнене, але жодного разу не проганялось. + ID: "r-own", Name: "Наш NTP", Kind: "must_contain", + Pattern: "ntp server 10.0.0.1", Severity: "medium", + ConfigType: "running", Enabled: true, + }, + } + at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC) + results := []ComplianceResult{ + {RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical", + DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false, + Line: "snmp-server community public RO", LineNumber: 7, CheckedAt: at}, + {RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical", + DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false, + Line: "snmp-server community private RW", LineNumber: 8, CheckedAt: at}, + {RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high", + DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false, + Line: "transport input telnet ssh", LineNumber: 12, + CheckedAt: at.Add(time.Minute)}, + {RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high", + DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: true, CheckedAt: at}, + } + return rules, results +} + +func TestComplianceReportOf(t *testing.T) { + rules, results := reportFixture() + at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + rep := ComplianceReportOf(at, "ops", false, rules, results) + + if rep.RulesTotal != 4 || rep.RulesEnabled != 3 || rep.RulesBuiltin != 3 { + t.Errorf("склад правил: усього %d, увімкнено %d, вбудованих %d; очікували 4/3/3", + rep.RulesTotal, rep.RulesEnabled, rep.RulesBuiltin) + } + // Два правила без жодної перевірки: вимкнене й те, що ще не + // проганялось. Без цього числа нулі в звіті означали б «усе гаразд». + if rep.RulesNeverRun != 2 { + t.Errorf("правил без перевірки = %d, очікували 2", rep.RulesNeverRun) + } + if rep.Checks != 4 || rep.Failed != 3 { + t.Errorf("перевірок %d, порушень %d; очікували 4 і 3", rep.Checks, rep.Failed) + } + if rep.Devices != 2 || rep.DevicesFailed != 2 { + t.Errorf("хостів %d, з них із порушеннями %d; очікували 2 і 2", + rep.Devices, rep.DevicesFailed) + } + if rep.LastCheckAt == nil || !rep.LastCheckAt.Equal( + time.Date(2026, 8, 28, 9, 31, 0, 0, time.UTC)) { + t.Errorf("остання перевірка = %v, очікували найпізнішу з результатів", rep.LastCheckAt) + } + + // Розподіл за серйозністю — з нулями. «Критичних 0» — це відповідь; + // відсутній рядок читається як «не перевіряли». + want := map[string]int{"critical": 2, "high": 1, "medium": 0, "low": 0, "info": 0} + if len(rep.BySeverity) != len(complianceSeverityOrder) { + t.Fatalf("рядків серйозності %d, очікували %d", + len(rep.BySeverity), len(complianceSeverityOrder)) + } + for i, s := range rep.BySeverity { + if s.Severity != complianceSeverityOrder[i] { + t.Errorf("порядок серйозностей збився на %d: %q", i, s.Severity) + } + if s.Count != want[s.Severity] { + t.Errorf("%s: %d, очікували %d", s.Severity, s.Count, want[s.Severity]) + } + } + + // Порада їде разом зі знахідкою: звіт читає той, хто цього конфігу + // ніколи не бачив. + if len(rep.Violations) != 3 { + t.Fatalf("порушень у переліку %d, очікували 3", len(rep.Violations)) + } + if rep.Violations[0].Severity != "critical" { + t.Errorf("перше порушення мусить бути найважче, а не %q", rep.Violations[0].Severity) + } + for _, v := range rep.Violations { + if v.Remediation == "" { + t.Errorf("порушення %q/%q без поради — читач не знатиме, що робити", + v.DeviceName, v.RuleName) + } + } + + // Стан правил у шапці: увімкнене без жодної перевірки мусить бути + // відрізнюване від увімкненого й перевіреного. + byName := map[string]ComplianceReportRule{} + for _, r := range rep.Rules { + byName[r.Name] = r + } + if r := byName["Наш NTP"]; r.Checked != 0 || !r.Enabled { + t.Errorf("правило «Наш NTP» мало бути ввімкненим і без перевірок: %+v", r) + } + if r := byName["SNMP: типові community"]; r.Checked != 2 || r.Failed != 2 { + t.Errorf("SNMP: перевірок %d, порушень %d; очікували 2 і 2", r.Checked, r.Failed) + } + if r := byName["Керування обмежене списком"]; r.Enabled { + t.Error("вимкнене правило мусить лишатись у складі звіту саме як вимкнене") + } +} + +// TestComplianceReportSkipsDisabledRuleResults — у підсумку немає +// відповідей на питання, яких більше не ставлять. +// +// ПРИЧИНА. RunCompliance вимкнене правило пропускає й старих його +// результатів не чіпає — і правильно робить: «вимкнути в себе» головна +// дія над вбудованим правилом, і платити за неї стертою картиною мережі +// немає за що. Але доки ці рядки рахувались нарівні з рештою, вимкнене +// правило давало порушення НАЗАВЖДИ: клієнт свідомо знімає вимогу, а +// звіт для аудитора й далі показує по ній чотири порушення з датою +// піврічної давнини. +// +// ЩО МУСИТЬ ЛИШИТИСЬ ВИДИМИМ: саме правило. Вимкнене правило зникнути зі +// звіту не може — звіт без нього читається як «вимоги немає», і це та +// сама помилка, від якої застерігає шапка ncm_compliance_report.go. +func TestComplianceReportSkipsDisabledRuleResults(t *testing.T) { + rules, results := reportFixture() + at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC) + + // r-acl вимкнене; додаємо йому старі знахідки — рівно те, що лежить + // у базі в клієнта, який колись прогнав перевірку, а тоді вимогу зняв. + stale := []ComplianceResult{ + {RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high", + DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false, + Line: "", LineNumber: 0, CheckedAt: at.Add(-90 * 24 * time.Hour)}, + {RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high", + DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false, + CheckedAt: at.Add(-90 * 24 * time.Hour)}, + } + + base := ComplianceReportOf(at, "ops", false, rules, results) + rep := ComplianceReportOf(at, "ops", false, rules, append(append([]ComplianceResult{}, results...), stale...)) + + if rep.Failed != base.Failed { + t.Errorf("порушень %d проти %d без старих рядків — у підсумок потрапили "+ + "відповіді на зняте питання", rep.Failed, base.Failed) + } + if len(rep.Violations) != len(base.Violations) { + t.Errorf("порушень у переліку %d, очікували %d", len(rep.Violations), len(base.Violations)) + } + for _, v := range rep.Violations { + if v.RuleName == "Керування обмежене списком" { + t.Error("знахідка вимкненого правила потрапила в перелік порушень") + } + } + for _, s := range rep.BySeverity { + if s.Severity == "high" && s.Count != 1 { + t.Errorf("високих порушень %d, очікували 1 — старі рядки додались у розподіл", s.Count) + } + } + + // А правило зі звіту нікуди не поділось — воно там саме як вимкнене. + var seen bool + for _, r := range rep.Rules { + if r.Name == "Керування обмежене списком" { + seen = true + if r.Enabled { + t.Error("правило мало лишитись вимкненим") + } + if r.Failed != 0 || r.Checked != 0 { + t.Errorf("вимкнене правило показує %d/%d — це числа з питання, якого не ставлять", + r.Failed, r.Checked) + } + } + } + if !seen { + t.Error("вимкнене правило зникло зі складу — звіт без нього читається як «вимоги немає»") + } +} + +// TestComplianceReportPartialScope — звіт, зроблений людиною, яка +// бачить не весь парк. +// +// Найдорожча помилка звіту: інженер філії вивантажує правду про свої +// хости, а виглядає це як правда про мережу. Підсумок мусить рахуватись +// ПІСЛЯ відсіву невидимого, інакше шапка суперечитиме переліку. +func TestComplianceReportPartialScope(t *testing.T) { + rules, results := reportFixture() + visible := results[:0:0] + for _, x := range results { + if x.DeviceID == "d-1" { + visible = append(visible, x) + } + } + rep := ComplianceReportOf(time.Now(), "branch-eng", true, rules, visible) + + if !rep.PartialScope { + t.Error("обмежена видимість мусить лишити слід у звіті") + } + if rep.Devices != 1 { + t.Errorf("хостів %d, очікували 1: рахувати треба видиме", rep.Devices) + } + if rep.Failed != len(rep.Violations) { + t.Errorf("шапка каже %d порушень, у переліку %d — звіт суперечить сам собі", + rep.Failed, len(rep.Violations)) + } +} + +// TestComplianceReportNeverRun — звіт, коли перевірки не було жодної. +// +// Найнебезпечніший стан із усіх: нулі скрізь, порожній перелік порушень. +// Читається як бездоганна мережа. +func TestComplianceReportNeverRun(t *testing.T) { + rules, _ := reportFixture() + rep := ComplianceReportOf(time.Now(), "ops", false, rules, nil) + + if rep.LastCheckAt != nil { + t.Error("останньої перевірки не було — поле мусить лишитись порожнім") + } + if rep.RulesNeverRun != len(rules) { + t.Errorf("без перевірки %d із %d правил", rep.RulesNeverRun, len(rules)) + } + + var buf strings.Builder + if err := ComplianceReportCSV(&buf, rep); err != nil { + t.Fatalf("CSV: %v", err) + } + out := buf.String() + if !strings.Contains(out, "не проводилась") { + t.Error("шапка мусить сказати, що перевірки не було") + } + if !strings.Contains(out, "перевірок не було") { + t.Error("порожній розділ порушень мусить пояснити себе, а не мовчати зеленим") + } +} + +func TestComplianceReportCSV(t *testing.T) { + rules, results := reportFixture() + at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + rep := ComplianceReportOf(at, "ops", false, rules, results) + + var buf strings.Builder + if err := ComplianceReportCSV(&buf, rep); err != nil { + t.Fatalf("CSV: %v", err) + } + out := buf.String() + + if !strings.HasPrefix(out, csvBOM) { + t.Error("без BOM український Excel читає UTF-8 як cp1251 — назви хостів стануть кракозябрами") + } + if !strings.Contains(out, "sep=;") { + t.Error("без sep=; Excel бере роздільником кому") + } + if !strings.Contains(out, "\r\n") { + t.Error("Excel чекає CRLF") + } + + // Шапка — те, що робить файл документом. Без дати й складу правил + // два роздруки з різними числами нічим пояснити. + for _, must := range []string{ + "Сформовано;2026-08-28T10:00:00Z", + "Правил усього;4", + "Порушень;3", + "Порушень, критична;2", + "СКЛАД ПРАВИЛ НА МОМЕНТ ЗВІТУ", + "ПОРУШЕННЯ", + } { + if !strings.Contains(out, must) { + t.Errorf("у звіті немає рядка %q", must) + } + } + + // Зразок правила — у складі. Це найважливіше поле шапки: за місяць + // його змінять, і без відбитка в звіті пояснити різницю чисел буде + // нічим. + if !strings.Contains(out, `community\s+(public|private)`) { + t.Error("склад правил мусить нести самі зразки, а не лише назви") + } + // Походження — теж: аудитор має відрізняти правило постачальника від + // написаного клієнтом. + if !strings.Contains(out, "вбудоване (snmp-default-community)") { + t.Error("склад правил мусить називати походження") + } + if !strings.Contains(out, "увімкнено, але жодної перевірки") { + t.Error("правило без жодної перевірки мусить бути видиме окремим станом") + } + if !strings.Contains(out, "вимкнено") { + t.Error("вимкнене правило мусить бути видиме як вимкнене") + } + if !strings.Contains(out, "line vty 0 4 / transport input ssh") { + t.Error("порада мусить бути поруч зі знахідкою") + } +} + +// TestComplianceReportCSVFormulaInjection — вивантаження не виконується +// в Excel. +// +// ЦІНА ПОМИЛКИ. У клітинку «Що знайдено» їде СИРИЙ рядок конфігу +// пристрою: текст, який у мережу поклав хтось із доступом до заліза — +// тобто, за задумом самої перевірки відповідності, потенційно не той, +// кому довіряють. Файл відкриває керівник або аудитор, у Excel, зі +// своїми правами. Клітинка `=cmd|'/C calc'!A0` виконується при +// відкритті. Звіт про безпеку конфігів, який виконує код на машині +// аудитора, — це рівно протилежне до того, чим він є. +// +// Перевіряються всі поля, що приходять ззовні, а не одне: захист, +// покладений на одну клітинку, забувають у сусідній. +func TestComplianceReportCSVFormulaInjection(t *testing.T) { + rules := []ComplianceRule{{ + ID: "r-1", Name: "=HYPERLINK(\"http://evil\",\"звіт\")", Kind: "must_not_contain", + Pattern: "+telnet", Severity: "critical", ConfigType: "running", Enabled: true, + Remediation: "@SUM(A1:A9)", + }} + results := []ComplianceResult{{ + RuleID: "r-1", RuleName: rules[0].Name, Severity: "critical", + DeviceID: "d-1", DeviceName: "-2+3+cmd|'/C calc'!A0", Passed: false, + Line: `=cmd|'/C calc'!A0`, LineNumber: 11, + CheckedAt: time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC), + }} + + var buf strings.Builder + if err := ComplianceReportCSV(&buf, ComplianceReportOf( + time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC), "ops", false, rules, results)); err != nil { + t.Fatalf("CSV: %v", err) + } + out := buf.String() + + // Небезпечні клітинки мусять бути екрановані апострофом — і не + // втратити при цьому вмісту: рядок конфігу лишається доказом, він + // лише перестає бути формулою. + for _, want := range []string{ + `'=cmd|'`, // рядок конфігу з пристрою + `'-2+3+cmd|'`, // імʼя хоста + `'=HYPERLINK(`, // назва правила + `'+telnet`, // зразок правила + `'@SUM(A1:A9)`, // порада «як виправити» + } { + if !strings.Contains(out, want) { + t.Errorf("у файлі немає %q — клітинка поїхала в Excel формулою", want) + } + } + + // Зворотний бік: жодна клітинка не сміє ПОЧИНАТИСЬ із небезпечного + // символу. Перелік вище доводить, що ми щось екранували; ця + // перевірка — що не лишилось нічого неекранованого. + for i, line := range strings.Split(out, "\r\n") { + for _, cell := range strings.Split(strings.TrimPrefix(line, csvBOM), ";") { + cell = strings.TrimPrefix(cell, `"`) + if cell == "" || !csvFormulaLead(cell[0]) { + continue + } + if _, err := strconv.ParseFloat(cell, 64); err == nil { + continue // число, а не формула + } + if strings.HasPrefix(line, "sep=") { + continue + } + t.Errorf("рядок %d: клітинка %q починається з символу формули", i+1, cell) + } + } +} + +// TestCSVSafeCellKeepsNumbers — екранування не ламає чисел. +// +// ПРИЧИНА окремої перевірки: `-5` починається з небезпечного символу й +// формулою не є. Екранувати його означало б перетворити число на текст — +// і стовпчик простою в SLA-звіті перестав би складатися. Читач звіту +// побачив би не помилку, а порожній підсумок. +func TestCSVSafeCellKeepsNumbers(t *testing.T) { + safe := []string{"-5", "-0.75", "+12", "99.912", "0", "core-sw-01", ""} + for _, s := range safe { + if got := csvSafeCell(s); got != s { + t.Errorf("csvSafeCell(%q) = %q — значення зіпсовано без потреби", s, got) + } + } + danger := []string{"=1+1", "+cmd|'/C calc'!A0", "-2+3+cmd", "@SUM(A1)", "\t=1+1", "\r=1+1"} + for _, s := range danger { + got := csvSafeCell(s) + if !strings.HasPrefix(got, "'") { + t.Errorf("csvSafeCell(%q) = %q — формула поїхала б у таблицю як формула", s, got) + } + if strings.TrimPrefix(got, "'") != s { + t.Errorf("csvSafeCell(%q) = %q — вміст клітинки втрачено", s, got) + } + } +} + +// TestComplianceReportCSVEndMarker — обрізаний файл видно як обрізаний. +// +// ПРИЧИНА. Звіт іде в потік ПІСЛЯ статусу 200: помилку запису посеред +// стріму обробник лише журналює, а в людини на диску лишається коротший +// файл. Обрізаний CSV відкривається, читається й виглядає повним — у +// ньому просто менше порушень. Кінцевий маркер робить різницю видимою +// тому, хто відкрив файл, а не тому, хто читає журнал сервера. +func TestComplianceReportCSVEndMarker(t *testing.T) { + rules, results := reportFixture() + rep := ComplianceReportOf(time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC), + "ops", false, rules, results) + + var buf strings.Builder + if err := ComplianceReportCSV(&buf, rep); err != nil { + t.Fatalf("CSV: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "КІНЕЦЬ ЗВІТУ") { + t.Fatal("без кінцевого маркера обрізаний файл не відрізнити від повного") + } + // Маркер мусить бути саме в КІНЦІ, інакше він нічого не доводить. + if !strings.HasPrefix(strings.TrimSpace(out[strings.LastIndex(out, "\r\n\r\n")+4:]), "КІНЕЦЬ ЗВІТУ") { + t.Error("маркер стоїть не в кінці файла") + } + // І мусить нести число, з яким можна звірити перелік вище. + if !strings.Contains(out, "порушень у файлі: "+strconv.Itoa(len(rep.Violations))) { + t.Error("маркер без числа не дає з чим звірити перелік") + } +} + +// TestComplianceReportStableOrder — два вивантаження того самого стану +// дають той самий файл. +// +// Не косметика: звіти порівнюють diff-ом («що змінилось за місяць»), і +// перетасований порядок рядків робить це порівняння нечитабельним — +// тобто скасовує єдиний спосіб побачити рух. +func TestComplianceReportStableOrder(t *testing.T) { + rules, results := reportFixture() + at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC) + + shuffled := []ComplianceResult{results[3], results[1], results[2], results[0]} + + var a, b strings.Builder + if err := ComplianceReportCSV(&a, ComplianceReportOf(at, "ops", false, rules, results)); err != nil { + t.Fatal(err) + } + if err := ComplianceReportCSV(&b, ComplianceReportOf(at, "ops", false, rules, shuffled)); err != nil { + t.Fatal(err) + } + if a.String() != b.String() { + t.Error("порядок результатів у вибірці не має впливати на файл звіту") + } +} + +func TestComplianceReportFileNameHasDate(t *testing.T) { + rep := ComplianceReport{GeneratedAt: time.Date(2026, 8, 28, 10, 5, 0, 0, time.UTC)} + got := ComplianceReportFileName(rep) + if got != "compliance-2026-08-28-1005.csv" { + t.Errorf("імʼя файла = %q; без дати десяток таких файлів у папці не розрізнити", got) + } +} + +// --------------------------------------------------------------------- +// 5. Чого тут НЕМАЄ +// --------------------------------------------------------------------- + +// TestNothingHereProvesTheWholeChain нічого не перевіряє. Це список меж, +// поставлений там, де його прочитають, — у файлі з тестами, а не в +// документі, який відкривають раз. +// +// НЕ ПОКРИТО ТУТ: +// +// 1. SaveComplianceRule / DeleteComplianceRule проти справжньої бази. +// Замок вбудованих правил перевірено на checkBuiltinImmutable — +// чистій функції. Що SaveComplianceRule справді читає поточний рядок +// ПІД БЛОКУВАННЯМ у тій самій транзакції й справді кличе цю функцію +// перед UPDATE, не доводить ніщо: ці рядки перевіряються лише +// прогоном проти Postgres (NETPULSE_TEST_DSN), який на машині +// розробника мовчки пропускається. +// +// 2. RLS. Що правило одного кабінету не видно з іншого й що звіт не +// збирає чужих хостів — тут не перевіряється взагалі. Ізоляція +// живе в політиках бази й у InTenantTx. +// +// 3. Відсів за Scope. Часткова видимість подана у ComplianceReportOf +// готовим прапорцем; що httpapi справді відсіює невидимі хости +// ПЕРЕД підрахунком (а не після), перевіряється лише читанням коду. +// Помилка тут дала б рівно ту суперечність, від якої застерігає +// TestComplianceReportPartialScope, — і цей тест її б не спіймав. +// +// 4. Розшифрування конфігу. ProbeComplianceRule ходить у ConfigBody із +// keyring; сервер без ключа відповідає 503. Перевірено вручну, +// тестом — ні. +// +// 5. Стирання результатів при зміні СЕЛЕКТОРА — лише ЧАСТКОВО. +// Порівняння самих представлень тепер перевірене без бази +// (TestSelectorComparedByValueNotByForm: `{}` із 0071 проти семи +// null-ключів із json.Marshal). Це та ланка, де жила помилка, і +// раніше її з тесту було вийнято готовим прапорцем. +// Але НЕ перевірено, що SaveComplianceRule справді читає збережений +// селектор тим самим SELECT ... FOR UPDATE і справді кличе +// complianceSubjectChanged перед DELETE, — це видно лише прогоном +// проти Postgres. +// +// 6. Прибирання результатів по хостах, які випали із селектора +// (pruneComplianceResults), і фільтри `cr.enabled` / +// `d.deleted_at IS NULL` у ListComplianceResults. Це SQL, і без +// NETPULSE_TEST_DSN його не перевіряє ніщо. Пораховано в Go тільки +// другий заслін — що ComplianceReportOf не рахує результатів +// вимкненого правила (TestComplianceReportSkipsDisabledRuleResults). +// Для видаленого хоста другого заслону немає взагалі: результат +// несе device_id, а не ознаку «хост живий», і звіт про це не знає. +// +// 7. Відсів за Scope В ЗАПИТІ вендорів (ListComplianceVendors) — +// той самий випадок: предикат у SQL, тестів без бази немає. +// ApplyComplianceCounts перевірено як чиста функція, але що обробник +// годує її саме видимими результатами, доводить лише читання коду. +// +// 8. CSV у справжньому Excel. Перевіряється наявність BOM, `sep=;`, +// CRLF і апострофа перед формулою — тобто те, що МАЄ змусити Excel +// прочитати файл правильно й не виконати клітинку. Що він справді +// так робить — і що те саме роблять LibreOffice, Numbers і Google +// Sheets, у яких свої переліки небезпечних символів, — може довести +// лише сам табличний процесор. +// +// 9. Форма. Перевірки сторінки живуть у web/src/test/compliance.test.tsx і +// ходять до ПІДСТАВНОГО сервера. Що справжні обробники віддають +// саме ту форму, яку там підставлено, не доводить ні той файл, ні цей. +// +// 10. Головне: жоден тест не доводить, що набір правил ПОВНИЙ. Двадцять +// правил, які всі до одного зелені, і повна відсутність правил про +// цілий клас ризиків дають на екрані однакову картинку. Вигляд +// ncm.builtin_compliance_coverage із 0071 відповідає на це для +// вбудованих правил і не відповідає для написаних руками. +func TestNothingHereProvesTheWholeChain(t *testing.T) { + t.Skip("перелік меж покриття; сам нічого не перевіряє — читайте коментар вище") +} diff --git a/server/internal/store/ncm_compliance_probe.go b/server/internal/store/ncm_compliance_probe.go new file mode 100644 index 0000000..de56c48 --- /dev/null +++ b/server/internal/store/ncm_compliance_probe.go @@ -0,0 +1,320 @@ +package store + +import ( + "context" + "regexp" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/crypto" +) + +// Перевірка зразка правила на справжньому конфізі. +// +// НАВІЩО ЦЕ ОКРЕМИЙ ШЛЯХ, А НЕ «ЗБЕРЕГТИ Й ПОДИВИТИСЬ» +// +// Регулярний вираз — єдине поле правила, помилка в якому не виглядає як +// помилка. Зразок `^\s*transport input telnet` із зайвим пробілом або з +// `s` замість `\s` не компілюється — і про це скаже валідація. А от +// зразок, який компілюється й не збігається НІКОЛИ, дає рівно ту саму +// картинку, що й бездоганно налаштована мережа: «перевірено 6, порушень +// 0». Відрізнити ці два випадки за результатом прогону неможливо в +// принципі — обидва дають нуль. +// +// Тому єдиний спосіб — показати конфіг. Не «скільки порушень знайшлось», +// а «ось рядки, на які твій зразок наліг, ось їхні номери». Порожній +// перелік на конфізі, де людина ЗНАЄ, що telnet увімкнений, — це відповідь +// на питання, яке інакше не ставиться. +// +// Взірець тут — InterfaceFilterEditor/PreviewInterfaceFilter: та сама +// думка, той самий здогад про те, що зразок, застосований наосліп, або +// збирає не те, або не збирає нічого. +// +// ЧОМУ ДО ЗБЕРЕЖЕННЯ. Зберегти правило, щоб побачити, чи воно правильне, +// означає застосувати неперевірений зразок до всього парку саме для +// того, щоб дізнатись, чи він правильний. Плюс SaveComplianceRule +// стирає результати попереднього прогону — тобто ціна однієї спроби +// «а якщо так?» була б у стертій картині мережі. + +// ComplianceProbeDevice — хост, на якому можна перевірити зразок. +type ComplianceProbeDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + // Lines — розмір найсвіжішого конфігу потрібного типу в рядках. + // Стоїть у списку навмисно: зразок перевіряють на найбільшому + // конфізі, бо саме там є чому не збігтися. + Lines int `json:"lines"` + CollectedAt time.Time `json:"collected_at"` +} + +// ComplianceProbeMatch — один рядок конфігу, на який наліг зразок. +type ComplianceProbeMatch struct { + LineNumber int `json:"line_number"` + Line string `json:"line"` +} + +// ComplianceProbe — результат прогону зразка по одному конфігу. +type ComplianceProbe struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + Vendor string `json:"vendor,omitempty"` + ConfigID string `json:"config_id,omitempty"` + ConfigType string `json:"config_type"` + CollectedAt time.Time `json:"collected_at"` + Lines int `json:"lines"` + + // NoConfig — у хоста немає конфігу цього типу. + // + // Окреме поле, а не порожній перелік збігів: «конфіг не збирали» і + // «зразок нічого не знайшов» — різні відповіді, і плутати їх тут + // небезпечніше, ніж будь-де. Людина, яка бачить нуль збігів, робить + // висновок про ЗРАЗОК; якщо насправді не було чого читати, вона + // виправить те, що працює. + NoConfig bool `json:"no_config"` + + // InSelector — чи підпадає цей хост під селектор правила. + // + // Перевірити зразок можна й на хості поза селектором — саме так + // шукають, чи не звужений селектор зайве. Але сказати про це треба + // вголос: збіг на хості, до якого правило не застосовується, у + // прогоні не з'явиться. + InSelector bool `json:"in_selector"` + + // Matches — УСІ збіги, а не перший. + // + // Прогін зупиняється на першому (checkCompliance), і для висновку + // цього досить. Але людина в цю мить відповідає не на питання + // «порушення чи ні», а на питання «мій зразок ловить те, що я + // думаю»; один рядок на це не відповідає, а десять — відповідають. + Matches []ComplianceProbeMatch `json:"matches"` + Truncated bool `json:"truncated"` + + // Passed — той самий висновок, який зробив би прогін. + // + // Рахується тією ж checkCompliance, а не повторним `if kind == ...` + // тут: другий опис того самого розійшовся б із першим на найближчій + // правці, і форма почала б обіцяти не те, що зробить перевірка. + Passed bool `json:"passed"` +} + +// complianceProbeMax — стеля переліку збігів. +// +// Зразок `.` збігається з кожним рядком стотисячного конфігу; віддати +// їх усі означало б покласти мегабайти в JSON заради того, щоб людина +// подивилась на перші п'ятнадцять. Стеля видима (Truncated) — інакше +// обрізаний перелік читався б як повний. +const complianceProbeMax = 200 + +// ListComplianceProbeDevices — на чому можна перевірити зразок. +// +// Лише хости, у яких є конфіг ПОТРІБНОГО типу: у переліку, куди +// потрапляють усі, половина пунктів давала б «конфіг не збирали», і +// вибір хоста перетворився б на гру у вгадування. +func (s *Store) ListComplianceProbeDevices(ctx context.Context, tenantID, configType string) ([]ComplianceProbeDevice, error) { + if !complianceConfigTypes[configType] { + configType = "running" + } + out := []ComplianceProbeDevice{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // DISTINCT ON — найсвіжіша версія на хост, а не всі версії: + // перевіряти зразок на торішньому конфізі означає перевіряти + // його не на тому, на чому працюватиме прогін. + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(d.vendor,''), COALESCE(d.model,''), + c.line_count, c.collected_at + FROM ( + SELECT DISTINCT ON (device_id) + device_id, COALESCE(line_count,0) AS line_count, collected_at + FROM ncm.configs + WHERE tenant_id = $1 AND config_type = $2 + ORDER BY device_id, collected_at DESC + ) c + JOIN inv.devices d ON d.id = c.device_id + WHERE d.tenant_id = $1 AND d.deleted_at IS NULL + ORDER BY c.line_count DESC, d.name + LIMIT 200 + `, tenantID, configType) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d ComplianceProbeDevice + if err := rows.Scan(&d.ID, &d.Name, &d.Vendor, &d.Model, + &d.Lines, &d.CollectedAt); err != nil { + return err + } + out = append(out, d) + } + return rows.Err() + }) + return out, err +} + +// ListComplianceVendors — вендори, які справді є в кабінеті. +// +// Не довідник відомих вендорів, а те, що стоїть у мережі. Селектор із +// вендором, якого в парку немає, — це правило, яке не перевіряє нічого й +// показує «порушень немає»: та сама тиха брехня, лише зайшла з іншого +// боку. Список із живого інвентарю не дає її написати випадково. +// +// Scope тут не для зручності, а тому, що це АГРЕГАТ: перелік вендорів +// розповідає про склад парку, і зібраний по всьому кабінету він +// повідомляє інженерові філії, що десь стоїть Juniper, якого йому +// бачити не належить. Сусідній перелік хостів (ListComplianceProbeDevices) +// відсівається за тим самим Scope в обробнику; тут відсіяти в Go нема з +// чого — сам вендор не має ідентифікатора хоста, — тож фільтр іде в SQL. +func (s *Store) ListComplianceVendors(ctx context.Context, tenantID string, sc Scope) ([]string, error) { + out := []string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT DISTINCT d.vendor FROM inv.devices d + WHERE d.tenant_id = $1 AND d.deleted_at IS NULL + AND d.vendor IS NOT NULL AND d.vendor <> '' + AND ($2::boolean OR d.id = ANY($3::uuid[])) + ORDER BY d.vendor + `, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return err + } + out = append(out, v) + } + return rows.Err() + }) + return out, err +} + +// ProbeComplianceRule проганяє зразок по конфігу одного хоста. +// +// Правило сюди приходить НЕ збереженим — рівно в тому вигляді, у якому +// його зараз набрали у формі. +func (s *Store) ProbeComplianceRule( + ctx context.Context, + tenantID, deviceID string, + r ComplianceRule, + ring *crypto.Keyring, +) (ComplianceProbe, error) { + var out ComplianceProbe + + // Та сама валідація, що й при збереженні. Інакше форма показувала б + // зелену перевірку зразка, який зберегти однаково не вийде. + if err := validateComplianceRule(r); err != nil { + return out, err + } + + out.DeviceID = deviceID + out.ConfigType = r.ConfigType + + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT d.name, COALESCE(d.vendor,'') + FROM inv.devices d + WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL + `, deviceID, tenantID).Scan(&out.DeviceName, &out.Vendor) + }); err != nil { + if isNoRows(err) { + return out, ErrNotFound + } + return out, err + } + + // Чи підпадає хост під селектор — питання окреме від зразка, і + // відповідь на нього рахує рівно той код, що й прогін. + targets, err := s.complianceTargets(ctx, tenantID, r.Selector) + if err != nil { + return out, err + } + for _, id := range targets { + if id == deviceID { + out.InSelector = true + break + } + } + + // Компілюємо ДО читання конфігу, а не після: далі є гілка «конфігу + // немає», яка теж кличе checkCompliance, і nil-регексп у ній + // впав би паникою рівно на тому хості, про який і так нема що + // сказати. + var re *regexp.Regexp + if r.Kind == "regex_match" || r.Kind == "regex_absent" { + // Помилка компіляції вже відсіяна validateComplianceRule вище. + re = regexp.MustCompile(r.Pattern) + } + + configID, body, err := s.latestConfigBody(ctx, tenantID, deviceID, r.ConfigType, ring) + if err != nil { + return out, err + } + out.Matches = []ComplianceProbeMatch{} + if configID == "" { + out.NoConfig = true + // Порожній конфіг — не «пройдено». Для «має бути» це провал, для + // «не має бути» — успіх; хай висновок і тут робить checkCompliance, + // щоб форма не мала власної думки про порожнечу. + out.Passed, _, _ = checkCompliance(r, re, "") + return out, nil + } + out.ConfigID = configID + + out.Lines = len(strings.Split(body, "\n")) + out.Matches, out.Truncated = probeMatches(r, re, body) + out.Passed, _, _ = checkCompliance(r, re, body) + + // Час збору конфігу — не оздоба. Зразок, перевірений на конфізі + // тримісячної давнини, нічого не каже про те, що на залізі зараз. + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT collected_at FROM ncm.configs WHERE id = $1 AND tenant_id = $2 + `, configID, tenantID).Scan(&out.CollectedAt) + }); err != nil && !isNoRows(err) { + return out, err + } + + return out, nil +} + +// probeMatches — усі рядки конфігу, на які наліг зразок. +// +// Окремою функцією від ProbeComplianceRule навмисно, і причина рівно +// та, про яку весь цей файл: правило зі зразком, що не збігається +// НІКОЛИ, не відрізнити від справного за жодним підсумком. Єдиний +// доказ, що зразок працює, — перелік рядків. Отже, саме цей +// перелік і треба покрити тестом — а тест, який вимагає Postgres, на +// машині розробника мовчки пропускається (NETPULSE_TEST_DSN), +// тобто доказу не було б. +// +// Обхід НЕ переривається на першому збігу, на відміну від +// checkCompliance: для висновку «порушення чи ні» досить першого, а для +// відповіді на питання «мій зразок ловить те, що я думаю» — ні. +func probeMatches(r ComplianceRule, re *regexp.Regexp, body string) ([]ComplianceProbeMatch, bool) { + out := []ComplianceProbeMatch{} + for i, ln := range strings.Split(body, "\n") { + 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 + } + if len(out) >= complianceProbeMax { + return out, true + } + out = append(out, ComplianceProbeMatch{ + LineNumber: i + 1, + Line: strings.TrimSpace(ln), + }) + } + return out, false +} diff --git a/server/internal/store/ncm_compliance_report.go b/server/internal/store/ncm_compliance_report.go new file mode 100644 index 0000000..f2be053 --- /dev/null +++ b/server/internal/store/ncm_compliance_report.go @@ -0,0 +1,563 @@ +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() +} diff --git a/server/internal/store/ncm_report.go b/server/internal/store/ncm_report.go index 508f759..ec2f600 100644 --- a/server/internal/store/ncm_report.go +++ b/server/internal/store/ncm_report.go @@ -320,7 +320,12 @@ func writeCSVReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error { return err } - cw := csv.NewWriter(w) + // Через safeCSV: у клітинку `output` їде СИРИЙ вивід команди з + // пристрою, а в `command` — те, що набрала людина. Клітинка, яка + // починається з `=`, для Excel не текст, а формула, і виконає її та + // сама людина, яка відкрила звіт. Одне місце екранування на всі три + // наші csv — у csv_safe.go. + cw := newSafeCSV(csv.NewWriter(w)) if err := cw.Write([]string{ "run_id", "host", "address", "status", "duration_ms", "started_at", "finished_at", "command", "prep", "output", diff --git a/server/internal/store/sla_csv.go b/server/internal/store/sla_csv.go index ba97e3e..c0349c3 100644 --- a/server/internal/store/sla_csv.go +++ b/server/internal/store/sla_csv.go @@ -48,10 +48,16 @@ func SLAReportCSV(w io.Writer, rep SLAReport) error { return err } - cw := csv.NewWriter(w) - cw.Comma = ';' + raw := csv.NewWriter(w) + raw.Comma = ';' // CRLF — те, чого чекає Excel. Для решти світу різниці немає. - cw.UseCRLF = true + raw.UseCRLF = true + // Через safeCSV: назва цілі й назви хостів приходять від людини, а + // клітинка, що починається з `=`, для Excel не текст, а формула. + // Пояснення повністю — у csv_safe.go; тут навмисно немає власного + // екранування, бо третій його примірник розійшовся б із двома + // іншими. + cw := newSafeCSV(raw) // Шапка з умовами звіту. Вона й є те, що робить файл документом: // без неї «99.912» не відповідає на питання «за що і проти чого». diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c094d3d..4bf7639 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,9 @@ import type { InterfaceFilter, InterfaceFilterDevice, InterfaceFilterPreview, + ComplianceProbe, + ComplianceProbeDevice, + ComplianceReport, ComplianceResult, ComplianceRule, ComplianceStat, @@ -1226,6 +1229,84 @@ export const api = { runCompliance: () => request('/api/v1/ncm/compliance/run', { method: 'POST' }), + /** + * На чому можна перевірити зразок і які вендори взагалі є в парку. + * + * Однією ручкою навмисно: вибір хоста для перевірки й селектор + * вендорів — це одне питання «що в мене стоїть», і зібрані двома + * запитами в різні секунди відповіді суперечили б одна одній. + */ + listComplianceProbeDevices: (configType: string) => + request<{ devices: ComplianceProbeDevice[]; vendors: string[] }>( + `/api/v1/ncm/compliance/probe-devices?config_type=${encodeURIComponent(configType)}`, + ).then((r) => ({ devices: r.devices ?? [], vendors: r.vendors ?? [] })), + + /** + * Прогнати НЕЗБЕРЕЖЕНИЙ зразок по справжньому конфізу. + * + * POST, хоч нічого й не змінює: правило — структура з селектором і + * зразком, у якому живуть скісні риски й лапки, і складати його в + * рядок запиту означало б винайти власне кодування заради того, щоб + * виглядати як GET. + * + * Причина, з якої ця ручка взагалі існує: зразок, який компілюється й + * не збігається НІКОЛИ, дає «порушень немає» — вигляд бездоганної + * мережі. Зберегти правило, щоб це перевірити, не можна: збереження + * стирає результати попереднього прогону. + */ + probeComplianceRule: (deviceID: string, rule: Partial) => + request('/api/v1/ncm/compliance/probe', { + method: 'POST', + body: JSON.stringify({ device_id: deviceID, rule }), + }), + + complianceReport: () => + request('/api/v1/ncm/compliance/report'), + + /** + * Вивантаження звіту. + * + * CSV збирає сервер, а не браузер, — з тієї ж причини, що й звіт SLA: + * саме цей файл кладуть у папку до аудиту й потім звіряють із тим, що + * показує сторінка. Два підрахунки одного й того самого розійдуться, і + * побачить це не той, хто помилився. + * + * Тягнемо через fetch, а не звичайним посиланням: посилання не вміє + * додати заголовок Authorization. + */ + downloadComplianceReport: async () => { + const headers: Record = {} + const t = authToken() + if (t) headers.Authorization = `Bearer ${t}` + + const res = await fetch('/api/v1/ncm/compliance/report.csv', { + headers, + credentials: 'same-origin', + }) + if (!res.ok) { + // Помилка приходить у JSON, а успіх — у CSV. Розбирати тіло + // однаково не можна: на помилці воно вже не той тип. + let message = res.statusText + try { + const body = await res.json() + message = body?.error?.message ?? message + } catch { + // Тіло не JSON — лишається код відповіді. + } + throw new ApiError(res.status, 'download_failed', message) + } + const blob = await res.blob() + const name = + /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? + 'compliance.csv' + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.click() + URL.revokeObjectURL(url) + }, + // --- масове виконання команд --- /** diff --git a/web/src/pages/CompliancePage.tsx b/web/src/pages/CompliancePage.tsx index 8b4877b..2dc710d 100644 --- a/web/src/pages/CompliancePage.tsx +++ b/web/src/pages/CompliancePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { api } from '../api/client' import { session } from '../api/session' import { @@ -8,11 +8,13 @@ import { DataTable, ErrorNote, Field, + FieldGap, Modal, PageBody, PageHeader, Spinner, StatusBadge, + Tabs, Toggle, ago, inputClass, @@ -20,7 +22,14 @@ import { } from '../components/ui' import type { ConfirmRequest } from '../components/ui' import { Picker } from '../components/Picker' -import type { ComplianceResult, ComplianceRule, DeviceGroup } from '../types' +import type { + ComplianceProbe, + ComplianceProbeDevice, + ComplianceReport, + ComplianceResult, + ComplianceRule, + DeviceGroup, +} from '../types' /** * Відповідність конфігів вимогам. @@ -32,6 +41,15 @@ import type { ComplianceResult, ComplianceRule, DeviceGroup } from '../types' * Перевірка читає вже зібрані конфіги й не створює жодної сесії до * заліза, тому кнопка «Перевірити» тут без попереджень: вона нічого не * коштує мережі. + * + * ГОЛОВНЕ ПРО ЦЮ СТОРІНКУ. Найнебезпечніше поле правила — зразок. + * Помилковий зразок не падає й не попереджає: він компілюється, + * зберігається, проганяється — і повідомляє «порушень немає» на мережі, + * де порушення є. Тобто виглядає ІДЕАЛЬНО. Тому редактор правил тут не + * форма з полями, а форма з ПЕРЕВІРКОЮ НА СПРАВЖНЬОМУ КОНФІЗІ (див. + * PatternProbe нижче); без неї він був би генератором тихої брехні. + * Взірець — InterfaceFilterEditor, який робить рівно це для фільтра + * портів. */ const KINDS: { key: string; label: string; hint: string }[] = [ @@ -65,6 +83,7 @@ const SEVERITIES: { key: string; label: string }[] = [ const kindLabel = Object.fromEntries(KINDS.map((k) => [k.key, k.label])) const sevLabel = Object.fromEntries(SEVERITIES.map((s) => [s.key, s.label])) +const cfgLabel = Object.fromEntries(CONFIG_TYPES.map((t) => [t.key, t.label])) // Важкість тут своя, не алертна: правило відповідності описує ризик, а // не подію. Кольори підібрані так само, щоб очі не перевчались. @@ -76,9 +95,31 @@ const sevTone: Record = { info: 'info', } +const isRegex = (kind: string) => kind === 'regex_match' || kind === 'regex_absent' + +/** + * Виміри селектора, яких форма правила НЕ редагує. + * + * Форма знає про групи й вендорів. Решту складу хостів кладуть через API + * або з інших екранів, і вона мусить пережити правку правила незмінною — + * інакше «змінити важкість» тихо перетворює правило на три хости в + * правило на весь парк. Перелік потрібен, щоб назвати такий вимір + * людині, а не показати їй порожні поля замість звуження. + */ +const SELECTOR_DIM_LABEL: Record = { + device_ids: 'окремі хости', + site_ids: 'майданчики', + kinds: 'типи хостів', + template_ids: 'шаблони', + tags: 'мітки', +} + +type Tab = 'rules' | 'findings' | 'report' + export function CompliancePage() { const canWrite = session.can('ncm:write') + const [tab, setTab] = useState('rules') const [rules, setRules] = useState([]) const [results, setResults] = useState([]) const [groups, setGroups] = useState([]) @@ -86,7 +127,7 @@ export function CompliancePage() { const [running, setRunning] = useState(false) const [onlyFailed, setOnlyFailed] = useState(true) const [editing, setEditing] = useState(null) - const [creating, setCreating] = useState(false) + const [draft, setDraft] = useState | null>(null) const [err, setErr] = useState(null) const [confirm, setConfirm] = useState(null) @@ -134,6 +175,25 @@ export function CompliancePage() { } } + /** + * Увімкнути / вимкнути правило прямо в рядку. + * + * Надсилаємо правило ЦІЛКОМ, а не одне поле: ручка збереження приймає + * повний об'єкт, і надіслати частину означало б затерти решту + * умовчаннями. Для вбудованого правила це ще й єдиний спосіб пройти + * серверний замок: він порівнює назву, вид, зразок і тип конфігу з + * тим, що лежить у базі, — тобто вимагає надіслати їх незмінними. + */ + async function toggle(r: ComplianceRule, enabled: boolean) { + setErr(null) + try { + await api.saveComplianceRule(r.id, { ...r, enabled }) + await reload() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + } + const failed = results.filter((r) => !r.passed).length return ( @@ -154,7 +214,7 @@ export function CompliancePage() { {running ? 'Перевірка…' : 'Перевірити'} {canWrite && ( - )} @@ -169,174 +229,70 @@ export function CompliancePage() { ) : ( <> - -
- Правила -
- r.id} - empty="Правил ще немає — перевіряти конфіги нема на що" - columns={[ - { - key: 'name', - header: 'Назва', - cell: (r) => ( -
- {r.name} - {r.description && ( -
- {r.description} -
- )} -
- ), - }, - { - key: 'kind', - header: 'Умова', - hideOnMobile: true, - cell: (r) => ( - - {kindLabel[r.kind] ?? r.kind}{' '} - {r.pattern} - - ), - }, - { - key: 'sev', - header: 'Важкість', - cell: (r) => ( - - ), - }, - { - key: 'stat', - header: 'Стан', - cell: (r) => - r.failed > 0 ? ( - - {r.failed} з {r.failed + r.passed} не пройшли - - ) : r.passed > 0 ? ( - усі {r.passed} пройшли - ) : ( - ще не перевірялось - ), - }, - { - key: 'act', - header: '', - cell: (r) => - canWrite && ( -
- - -
- ), - }, - ]} - /> -
+ + active={tab} + onChange={setTab} + tabs={[ + { key: 'rules', label: 'Правила', badge: rules.length }, + { key: 'findings', label: 'Знахідки', badge: failed }, + { key: 'report', label: 'Звіт' }, + ]} + /> - -
- Знахідки - -
- `${r.rule_id}|${r.device_id}`} - empty={ - onlyFailed - ? 'Порушень немає — усі перевірені конфіги відповідають правилам' - : 'Перевірок ще не було' + {tab === 'rules' && ( + setDraft(copyOf(r))} + onToggle={toggle} + onDelete={(r) => + setConfirm({ + title: 'Видалити правило', + message: ( + <> + Правило {r.name} і його знахідки буде видалено. + + ), + confirmLabel: 'Видалити', + onConfirm: async () => { + await api.deleteComplianceRule(r.id) + await reload() + }, + }) } - columns={[ - { - key: 'device', - header: 'Хост', - cell: (r) => {r.device_name}, - }, - { - key: 'rule', - header: 'Правило', - cell: (r) => {r.rule_name}, - }, - { - key: 'line', - header: 'Що знайдено', - hideOnMobile: true, - cell: (r) => - r.line ? ( - - {r.line_number ? `${r.line_number}: ` : ''} - {r.line} - - ) : ( - - {r.passed ? '—' : 'нічого — саме це й проблема'} - - ), - }, - { - key: 'state', - header: 'Результат', - cell: (r) => - r.passed ? ( - пройдено - ) : ( - порушення - ), - }, - { - key: 'when', - header: 'Перевірено', - hideOnMobile: true, - cell: (r) => {ago(r.checked_at)} тому, - }, - ]} /> -
+ )} + + {tab === 'findings' && ( + + )} + + {tab === 'report' && } )} - {(creating || editing) && ( + {(draft || editing) && ( { + setEditing(null) + setDraft(r) + }} onClose={() => { - setCreating(false) + setDraft(null) setEditing(null) }} onSaved={() => { - setCreating(false) + setDraft(null) setEditing(null) void reload() }} @@ -348,38 +304,741 @@ export function CompliancePage() { ) } +/** + * Копія вбудованого правила. + * + * `id` і `builtin_key` не переносяться навмисно: копія — це СВОЄ + * правило, за яким не стоїть довідник і його перевірка. Саме тому її + * можна правити цілком. + */ +function copyOf(r: ComplianceRule): Partial { + return { + name: `${r.name} (копія)`, + description: r.description, + kind: r.kind, + pattern: r.pattern, + severity: r.severity, + config_type: r.config_type, + remediation: r.remediation, + selector: r.selector, + // Копія приходить вимкненою. Увімкнена одразу, вона задвоїла б + // знахідки з оригіналом — той самий telnet на тому самому хості + // двома рядками, і незрозуміло, який із них справжній. + enabled: false, + } +} + +// --------------------------------------------------------------------- +// Правила +// --------------------------------------------------------------------- + +function RulesCard({ + rules, + canWrite, + onEdit, + onCopy, + onToggle, + onDelete, +}: { + rules: ComplianceRule[] + canWrite: boolean + onEdit: (r: ComplianceRule) => void + onCopy: (r: ComplianceRule) => void + onToggle: (r: ComplianceRule, enabled: boolean) => void + onDelete: (r: ComplianceRule) => void +}) { + const off = rules.filter((r) => !r.enabled).length + + return ( + +
+ Правила + {off > 0 && ( + + {off} {plural(off, ['вимкнене', 'вимкнені', 'вимкнених'])} — вимкнене правило не дає + порушень, і це не те саме, що «вимога виконана» + + )} +
+ r.id} + empty="Правил ще немає — перевіряти конфіги нема на що" + columns={[ + { + key: 'name', + header: 'Назва', + cell: (r) => ( +
+ + {r.name} + + {r.builtin_key && ( + + вбудоване + + )} + {r.description && ( +
{r.description}
+ )} +
+ ), + }, + { + key: 'kind', + header: 'Умова', + hideOnMobile: true, + cell: (r) => ( + + {kindLabel[r.kind] ?? r.kind}{' '} + {r.pattern} + + ), + }, + { + key: 'sev', + header: 'Важкість', + cell: (r) => ( + + ), + }, + { + key: 'stat', + header: 'Стан', + cell: (r) => + !r.enabled ? ( + вимкнено + ) : r.failed > 0 ? ( + + {r.failed} з {r.failed + r.passed} не пройшли + + ) : r.passed > 0 ? ( + усі {r.passed} пройшли + ) : ( + // Найважливіший стан у цій колонці. Увімкнене правило + // без жодної перевірки виглядає так само зелено, як + // перевірене й чисте, — і означає протилежне. + + ще не перевірялось + + ), + }, + { + key: 'act', + header: '', + cell: (r) => + canWrite && ( +
+ + + + {r.builtin_key ? ( + + ) : ( + + )} +
+ ), + }, + ]} + /> +
+ ) +} + +// --------------------------------------------------------------------- +// Знахідки +// --------------------------------------------------------------------- + +function FindingsCard({ + results, + rules, + onlyFailed, + setOnlyFailed, +}: { + results: ComplianceResult[] + rules: ComplianceRule[] + onlyFailed: boolean + setOnlyFailed: (v: boolean) => void +}) { + // Порада береться з правила, а не з результату: у знахідці її немає, а + // читає її той, хто цього конфігу ніколи не бачив. + const remediation = useMemo( + () => Object.fromEntries(rules.map((r) => [r.id, r.remediation ?? ''])), + [rules], + ) + + return ( + +
+ Знахідки + +
+ `${r.rule_id}|${r.device_id}`} + empty={ + onlyFailed + ? 'Порушень немає — усі перевірені конфіги відповідають правилам' + : 'Перевірок ще не було' + } + columns={[ + { + key: 'device', + header: 'Хост', + cell: (r) => {r.device_name}, + }, + { + key: 'rule', + header: 'Правило', + cell: (r) => ( +
+ {r.rule_name} + {!r.passed && remediation[r.rule_id] && ( +
+ {remediation[r.rule_id]} +
+ )} +
+ ), + }, + { + key: 'line', + header: 'Що знайдено', + hideOnMobile: true, + cell: (r) => + r.line ? ( + + {r.line_number ? `${r.line_number}: ` : ''} + {r.line} + + ) : ( + + {r.passed ? '—' : 'нічого — саме це й проблема'} + + ), + }, + { + key: 'state', + header: 'Результат', + cell: (r) => + r.passed ? ( + пройдено + ) : ( + порушення + ), + }, + { + key: 'when', + header: 'Перевірено', + hideOnMobile: true, + cell: (r) => {ago(r.checked_at)} тому, + }, + ]} + /> +
+ ) +} + +// --------------------------------------------------------------------- +// Звіт +// --------------------------------------------------------------------- + +/** + * Звіт — те, що показують керівнику або аудитору. + * + * Тягнеться окремою ручкою, а не збирається зі списків на екрані. Не + * заради економії коду: у файл, який кладуть у папку до аудиту, і в те, + * що показує сторінка, мусить іти ОДИН підрахунок. Два розійдуться, і + * побачить це не той, хто помилився, а клієнт. + */ +function ReportCard() { + const [rep, setRep] = useState(null) + const [err, setErr] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + api + .complianceReport() + .then(setRep) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + }, []) + + /** + * Ключі рядків звіту — за позицією, а не за назвою. + * + * ПРИЧИНА. Назва правила не унікальна: два своїх правила можна назвати + * однаково (сервер цього не забороняє й не має забороняти — назва це + * підпис для людини, а не ідентифікатор), а хост може порушити два + * правила з тим самим ім'ям. Однакові ключі React вважає одним рядком: + * другий просто не малюється або підмінює перший. + * + * НАСЛІДОК: у звіті, який кладуть аудитору, зникає РЯДОК ПОРУШЕННЯ — і + * зникає тихо, бо число в шапці лишається правильним. Розбіжність + * «порушень 18, у таблиці 17» пояснити буде нічим. + * + * Мапа за тотожністю об'єкта, а не indexOf у кожному рядку: рахується + * один раз на звіт. + */ + const rowKeys = useMemo(() => { + const m = new Map() + rep?.rules.forEach((r, i) => m.set(r, `rule-${i}`)) + rep?.violations.forEach((v, i) => m.set(v, `violation-${i}`)) + return m + }, [rep]) + + if (err) return {err} + if (!rep) return + + const worst = rep.by_severity.filter((s) => s.count > 0) + + return ( +
+ +
+ Зведення +
+ +
+
+ +
+ {/* + Шапка. Без дати й складу правил звіт не вартий нічого: за + місяць правила зміняться, і два роздруки з різними числами + не буде чим пояснити. + */} +
+ {new Date(rep.generated_at).toLocaleString()} + {rep.generated_by && {rep.generated_by}} + + {rep.last_check_at ? ( + new Date(rep.last_check_at).toLocaleString() + ) : ( + + не проводилась — нулі нижче означають «не питали» + + )} + + + {rep.devices} + {rep.devices_failed > 0 && ( + · з порушеннями {rep.devices_failed} + )} + + + {rep.rules_total} · увімкнено {rep.rules_enabled} · вбудованих {rep.rules_builtin} + + {rep.checks} +
+ +
+ 0 ? 'text-red-300' : 'text-emerald-400' + }`} + > + {rep.failed} + + + {plural(rep.failed, ['порушення', 'порушення', 'порушень'])} із {rep.checks}{' '} + {plural(rep.checks, ['перевірки', 'перевірок', 'перевірок'])} + + {worst.map((s) => ( + + ))} +
+ + {rep.partial_scope && ( +

+ Ви бачите не весь парк: у звіт потрапили лише хости, доступні вам. Числа вище + правдиві про них і НЕ описують мережу цілком — документ, у якому цього не сказано, + читатиметься як звіт про всю мережу. +

+ )} + + {rep.rules_never_run > 0 && ( +

+ {rep.rules_never_run}{' '} + {plural(rep.rules_never_run, ['правило', 'правила', 'правил'])} не дало жодної + перевірки: або вимкнене, або в жодного хоста немає конфігу потрібного типу, або не + підпав селектор. Нуль порушень у такого правила означає «не питали», а не «все + гаразд». +

+ )} + + {/* + PDF немає, і це рішення, а не недоробка. Пояснення стоїть + поруч із кнопкою, бо читає його той, хто цю кнопку шукає. + */} +

+ PDF немає навмисно. Зібрати його без сторонніх бібліотек означало б укласти в + репозиторій шрифт із кирилицею й написати підмножувач TrueType: базові шрифти PDF + української не мають, і звіт вийшов би або порожнім, або з обрізаними літерами — тобто + зіпсованим тихо, що для документа гірше за його відсутність. Якщо потрібен PDF, + надрукуйте цю сторінку в PDF засобами браузера: там уже є і шрифти, і кирилиця. +

+
+
+ + +
+ Склад правил на момент звіту +
+ rowKeys.get(r) ?? r.name} + empty="Правил немає" + columns={[ + { + key: 'name', + header: 'Правило', + cell: (r) => ( +
+ {r.name} +
+ {r.builtin_key ? `вбудоване · ${r.builtin_key}` : 'своє'} +
+
+ ), + }, + { + key: 'cond', + header: 'Умова', + hideOnMobile: true, + cell: (r) => ( + + {kindLabel[r.kind] ?? r.kind}{' '} + {r.pattern} +
+ {cfgLabel[r.config_type] ?? r.config_type} +
+
+ ), + }, + { + key: 'sev', + header: 'Важкість', + cell: (r) => ( + + ), + }, + { + key: 'state', + header: 'Стан', + cell: (r) => + !r.enabled ? ( + вимкнено + ) : r.checked === 0 ? ( + увімкнено, жодної перевірки + ) : r.failed > 0 ? ( + + {r.failed} з {r.checked} не пройшли + + ) : ( + усі {r.checked} пройшли + ), + }, + ]} + /> +
+ + +
+ Порушення +
+ rowKeys.get(v) ?? `${v.device_name}|${v.rule_name}`} + empty={ + rep.checks === 0 + ? 'Перевірок не було — це не «порушень немає»' + : 'Порушень немає — усі перевірені конфіги відповідають правилам' + } + columns={[ + { + key: 'device', + header: 'Хост', + cell: (v) => {v.device_name}, + }, + { + key: 'rule', + header: 'Правило', + cell: (v) => {v.rule_name}, + }, + { + key: 'sev', + header: 'Важкість', + cell: (v) => ( + + ), + }, + { + key: 'line', + header: 'Що знайдено', + hideOnMobile: true, + cell: (v) => + v.line ? ( + + {v.line_number ? `${v.line_number}: ` : ''} + {v.line} + + ) : ( + + рядка немає в конфігу — саме це й порушення + + ), + }, + { + key: 'fix', + header: 'Як виправити', + cell: (v) => + v.remediation ? ( + {v.remediation} + ) : ( + поради немає + ), + }, + ]} + /> +
+
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}:
+
{children}
+
+ ) +} + +// --------------------------------------------------------------------- +// Форма правила +// --------------------------------------------------------------------- + function RuleForm({ rule, + draft, groups, + onCopy, onClose, onSaved, }: { + /** Наявне правило, яке правлять. null — створення. */ rule: ComplianceRule | null + /** Заготовка для створення: порожня або копія вбудованого. */ + draft?: Partial groups: DeviceGroup[] + onCopy: (r: Partial) => void onClose: () => void onSaved: () => void }) { - const [name, setName] = useState(rule?.name ?? '') - const [description, setDescription] = useState(rule?.description ?? '') - const [kind, setKind] = useState(rule?.kind ?? 'must_contain') - const [pattern, setPattern] = useState(rule?.pattern ?? '') - const [severity, setSeverity] = useState(rule?.severity ?? 'medium') - const [configType, setConfigType] = useState(rule?.config_type ?? 'running') - const [remediation, setRemediation] = useState(rule?.remediation ?? '') - const [groupIDs, setGroupIDs] = useState(rule?.selector?.group_ids ?? []) - const [enabled, setEnabled] = useState(rule?.enabled ?? true) + const src = rule ?? draft ?? {} + + /** + * Вбудоване правило: предмет перевірки замкнено. + * + * ЩО ЗАМКНЕНО: назва, умова, зразок і тип конфігу — тобто питання, яке + * правило ставить. ЩО ВІДКРИТО: увімкнено/вимкнено, серйозність, склад + * хостів, опис і порада — тобто політика кабінету щодо цього питання. + * + * ЧОМУ. За вбудованим зразком стоїть доказ: перевірка на сервері + * показує для кожного конфіг, де він мусить спрацювати, і конфіг, де + * не мусить. Переписаний руками зразок цього доказу не має, а значок + * «вбудоване» лишився б — і у звіті, який кладуть аудитору, рядок + * «SNMP: типові community» означав би вже не те, що в довіднику. + * + * Щоб людина не гадала, чому поля сірі, поруч стоїть смуга з + * поясненням і кнопкою «Зробити копію», а не самі лише сірі поля. + */ + const builtin = rule?.builtin_key ?? '' + + const [name, setName] = useState(src.name ?? '') + const [description, setDescription] = useState(src.description ?? '') + const [kind, setKind] = useState(src.kind ?? 'must_contain') + const [pattern, setPattern] = useState(src.pattern ?? '') + const [severity, setSeverity] = useState(src.severity ?? 'medium') + const [configType, setConfigType] = useState(src.config_type ?? 'running') + const [remediation, setRemediation] = useState(src.remediation ?? '') + const [groupIDs, setGroupIDs] = useState(src.selector?.group_ids ?? []) + const [vendors, setVendors] = useState(src.selector?.vendors ?? []) + const [enabled, setEnabled] = useState(src.enabled ?? true) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + const [knownVendors, setKnownVendors] = useState([]) + const [devices, setDevices] = useState([]) + + useEffect(() => { + api + .listComplianceProbeDevices(configType) + .then((r) => { + setDevices(r.devices) + setKnownVendors(r.vendors) + }) + .catch(() => {}) + }, [configType]) + const hint = KINDS.find((k) => k.key === kind)?.hint + /** + * Селектор правила цілком, яким він приїхав. + * + * ПРИЧИНА. Форма редагує рівно два виміри складу хостів: групи й + * вендорів. Селектор має більше — окремі хости (`device_ids`), + * майданчики, типи, шаблони, мітки; їх кладуть через API або через + * інші екрани. Збирати селектор з нуля з двох полів означало б: будь-яка + * правка правила, звуженого до трьох хостів, МОВЧКИ розширює його на + * весь парк. + * + * НАСЛІДОК тієї помилки найгірший з можливих для цієї сторінки: правило + * після неї не падає й не попереджає — воно починає перевіряти більше, + * ніж домовлялись, або (для «має бути») сипле порушеннями на хостах, до + * яких ніколи не стосувалось. Тому незмінні виміри переносяться як є, а + * про їхню наявність сказано вголос нижче. + * + * Знімок робиться один раз, при відкритті: далі форма ним володіє, і + * перечитувати його з props означало б загубити правки. + */ + const [baseSelector] = useState(() => ({ ...(src.selector ?? {}) })) + + const selector = useMemo(() => { + const s: ComplianceRule['selector'] = { ...baseSelector } + delete s.group_ids + delete s.vendors + if (groupIDs.length > 0) s.group_ids = groupIDs + if (vendors.length > 0) s.vendors = vendors + return s + }, [baseSelector, groupIDs, vendors]) + + // Виміри селектора, яких ця форма не показує. Мовчазне поле — це поле, + // про яке людина дізнається з наслідків. + const hiddenDims = Object.entries(baseSelector) + .filter(([k, v]) => { + if (k === 'group_ids' || k === 'vendors' || v == null) return false + return Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0 + }) + .map(([k]) => SELECTOR_DIM_LABEL[k] ?? k) + + const candidate: Partial = { + name: name.trim(), + description: description.trim(), + kind, + pattern, + severity, + config_type: configType, + remediation: remediation.trim(), + enabled, + selector, + } + return ( - +
+ {builtin && ( +
+

+ Це вбудоване правило ({builtin}) — воно прийшло з довідника разом із + системою, і його зразок перевірено на справжніх конфігах кількох вендорів. Тому{' '} + назву, умову, зразок і тип конфігу тут змінити не можна: переписаний зразок + цієї перевірки за спиною вже не має, а виглядав би так само вбудованим — і в звіті + для аудитора рядок означав би не те, що в довіднику. +

+

+ Змінити вільно можна все, що стосується вашого кабінету: увімкнути чи + вимкнути, звузити склад хостів, знизити важкість, переписати пораду. Це ваша + політика, і копія правила у вас власна — сусідні кабінети вона не зачіпає. +

+
+ +
+
+ )} + setName(e.target.value)} /> @@ -395,7 +1054,12 @@ function RuleForm({
- setKind(e.target.value)} + > {KINDS.map((k) => ( - - ({ - id: g.id, - label: g.name, - hint: `${g.device_count} ${plural(g.device_count, ['хост', 'хости', 'хостів'])}`, - }))} - value={groupIDs} - onChange={setGroupIDs} - placeholder="усі хости" - /> - + {/* ---- Перевірка зразка на справжньому конфізі ---- */} + - +
+ + ({ + id: g.id, + label: g.name, + hint: `${g.device_count} ${plural(g.device_count, ['хост', 'хости', 'хостів'])}`, + }))} + value={groupIDs} + onChange={setGroupIDs} + placeholder="усі хости" + /> + + + {/* + Вендори — окремий вимір складу хостів, не заміна групам. + «Немає telnet» — це не одне правило: на Cisco це `transport + input`, на Huawei `telnet server enable`, на MikroTik + `set telnet disabled=no`. Правило з одним зразком, прив'язане + до всіх, каже правду про одну родину й МОВЧИТЬ про решту — + один рядок «0 порушень» і жодного способу побачити сліпу + пляму (0071, розділ 1.3). + + Перелік — із живого інвентарю, а не довідник відомих + вендорів: вендор, якого в парку немає, дав би правило, що не + перевіряє нічого й показує «порушень немає». + */} + 0 + ? 'Порожньо — усі вендори. Зразки команд у вендорів різні: правило під Cisco мовчатиме про MikroTik, і мовчання виглядатиме як «порушень немає»' + : 'У жодного хоста ще не визначено вендора — звузити нема за чим' + } + > + ({ id: v, label: v }))} + value={vendors} + onChange={setVendors} + placeholder="усі вендори" + /> + +
+ + {hiddenDims.length > 0 && ( +

+ Склад хостів цього правила звужений ще й за іншими вимірами:{' '} + {hiddenDims.join(', ')}. Тут їх не редагують, і збереження їх НЕ скидає — + правило лишиться таким же вузьким, як було. Порожні «Групи хостів» і «Вендори» вище не + означають «усі хости». +

+ )} + + 0 ? { group_ids: groupIDs } : {}, - }) + await api.saveComplianceRule(rule?.id ?? null, candidate) onSaved() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) @@ -511,3 +1210,242 @@ function RuleForm({ ) } + +// --------------------------------------------------------------------- +// Перевірка зразка на справжньому конфізі +// --------------------------------------------------------------------- + +/** + * Головна частина редактора правил. + * + * ЧОМУ ВОНА ТУТ. Зразок — єдине поле, помилка в якому не виглядає як + * помилка. Вираз, що не компілюється, відхилить сервер. А вираз, який + * компілюється й не збігається НІКОЛИ, збережеться, прожене й скаже + * «порушень немає» — тобто покаже бездоганну мережу там, де telnet + * відкритий на всіх шести хостах. За результатом прогону ці два випадки + * не відрізняються в принципі: обидва дають нуль. + * + * Тому тут показується не «скільки порушень», а РЯДКИ, на які зразок + * наліг, з номерами. Порожній перелік на конфізі, де людина знає, що + * telnet увімкнений, — це відповідь, якої інакше не отримати. + * + * ЧОМУ ДО ЗБЕРЕЖЕННЯ. Зберегти правило, щоб подивитись, означає + * застосувати неперевірений зразок до всього парку саме для того, щоб + * дізнатись, чи він правильний, — і заразом стерти результати + * попереднього прогону. + * + * Взірець — InterfaceFilterEditor: та сама думка про зразок, застосований + * наосліп. + */ +function PatternProbe({ + rule, + devices, + locked, +}: { + rule: Partial + devices: ComplianceProbeDevice[] + /** Вбудоване правило: зразок не правиться, але перевірити його можна й треба. */ + locked: boolean +}) { + const [deviceID, setDeviceID] = useState('') + const [probe, setProbe] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + // Типово — найбільший конфіг: зразок перевіряють на найгіршому + // випадку, бо саме там є чому не збігтися. Сервер віддає список уже + // в такому порядку. + setDeviceID((cur) => (devices.some((d) => d.id === cur) ? cur : (devices[0]?.id ?? ''))) + setProbe(null) + }, [devices]) + + const pattern = rule.pattern ?? '' + const kind = rule.kind ?? '' + const configType = rule.config_type ?? '' + + /** + * Правка предмета перевірки скидає показаний доказ. + * + * ПРИЧИНА. Результат унизу — це відповідь на конкретний зразок, + * конкретний вид умови й конкретний тип конфігу. Змініть будь-що з + * трьох — і рядки на екрані стосуються вже не того правила, яке зараз + * у формі. Скидання за хостом тут було з самого початку; за самим + * зразком — ні, і саме воно найважливіше: хост міняють рідко, зразок + * правлять по разу на кожну спробу. + * + * НАСЛІДОК, якби не скидали: людина проганяє зразок, бачить «6 + * збігів», підправляє одну літеру, зберігає — і зберігає вона + * НЕПЕРЕВІРЕНИЙ зразок, дивлячись при цьому на доказ, що все гаразд. + * Уся ця ручка існує рівно для того, щоб такого не сталося; лишений + * старий результат перетворює її на протилежне — і саме в мить + * найвищої довіри до нього. + */ + useEffect(() => { + setProbe(null) + }, [pattern, kind, configType]) + + async function run() { + if (!deviceID) return + setBusy(true) + setErr(null) + try { + setProbe(await api.probeComplianceRule(deviceID, rule)) + } catch (e) { + setProbe(null) + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + return ( +
+

+ {locked + ? 'Зразок вбудованого правила змінити не можна, але побачити, що він робить на вашому залізі, — можна й варто: вендор і прошивка вирішують, чи він узагалі щось знайде.' + : 'Зразок, який компілюється й не збігається ніколи, дає «порушень немає» — тобто виглядає ідеально й не перевіряє нічого. Єдиний спосіб відрізнити його від справного — подивитись на рядки, які він знайшов у справжньому конфізі.'} +

+ +
+
+ + + +
+ + + +
+ + {err} + + {devices.length === 0 && ( +

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

+ )} + + {probe && } +
+ ) +} + +function ProbeResult({ probe, kind }: { probe: ComplianceProbe; kind: string }) { + const wantsPresence = kind === 'must_contain' || kind === 'regex_match' + + if (probe.no_config) { + return ( +

+ У хоста {probe.device_name} немає конфігу типу «{cfgLabel[probe.config_type] ?? + probe.config_type} + ». Це не «зразок нічого не знайшов» — тут просто не було чого читати. У прогоні такий хост + рахується пропущеним. +

+ ) + } + + return ( +
+
+ + {probe.matches.length}{' '} + {plural(probe.matches.length, ['збіг', 'збіги', 'збігів'])} + + + у {probe.lines} {plural(probe.lines, ['рядку', 'рядках', 'рядках'])} конфігу від{' '} + {ago(probe.collected_at)} тому + + + вислід прогону: {probe.passed ? 'пройдено' : 'порушення'} + +
+ + {/* + НАЙВАЖЛИВІШЕ ПОПЕРЕДЖЕННЯ НА ЦІЙ СТОРІНЦІ. + + Нуль збігів у правилі «не має бути» означає «пройдено» — і рівно + так само виглядає правило з опискою на одну літеру. Різниці на + екрані немає жодної, тому її треба назвати словами. + */} + {probe.matches.length === 0 && !wantsPresence && ( +

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

+ )} + + {probe.matches.length === 0 && wantsPresence && ( +

+ Зразок не знайшов нічого — для правила «має бути» це порушення на цьому хості. Помилка в + зразку виглядатиме так само, тому переконайтесь на хості, де потрібний рядок точно є. +

+ )} + + {!probe.in_selector && ( +

+ Цей хост НЕ підпадає під склад хостів правила (групи або вендори). Зразок ви щойно + перевірили, але в прогоні цієї пари не буде. +

+ )} + + {probe.truncated && ( +

+ Збігів більше, ніж показано: перелік обрізано. Зразок надто широкий — він чіпляє мало не + кожен рядок. +

+ )} + + {probe.matches.length > 0 && ( +
+ + + + + + + + + {probe.matches.map((m) => ( + + + + + ))} + +
рядокщо знайдено
{m.line_number}{m.line}
+
+ )} +
+ ) +} diff --git a/web/src/test/compliance.test.tsx b/web/src/test/compliance.test.tsx new file mode 100644 index 0000000..c317674 --- /dev/null +++ b/web/src/test/compliance.test.tsx @@ -0,0 +1,723 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { CompliancePage } from '../pages/CompliancePage' +import { session } from '../api/session' +import { fetchRouter, res, type Call } from './support' + +/** + * Редактор правил відповідності та звіт. + * + * ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. + * + * Правило зі зразком, що не збігається НІКОЛИ, дає «порушень немає» — + * тобто виглядає бездоганно й не перевіряє нічого. За результатом + * прогону воно не відрізняється від справного: обидва дають нуль. Тому + * природний тест «зразок збігся — знахідка знайшлась» зелений і не + * вартий нічого сам по собі. + * + * Це той самий урок, на якому погорів тест ізоляції RLS: він перевіряв + * «чи не видно чужого», коли зламалось «чи видно своє». Тут кожна + * перевірка йде парою — зразок, що ловить, і зразок, що не ловить + * ніколи; другий мусить лишити слід, ВИДИМИЙ у формі. + */ + +const builtinRule = { + id: 'r-telnet', + name: 'Telnet вимкнено', + description: 'Керування лише по SSH', + kind: 'regex_absent', + pattern: 'transport input.*telnet', + severity: 'critical', + selector: {}, + remediation: 'line vty 0 4 / transport input ssh', + enabled: true, + config_type: 'running', + builtin_key: 'telnet-disabled', + failed: 4, + passed: 2, +} + +const ownRule = { + id: 'r-ntp', + name: 'Наш NTP', + kind: 'must_contain', + pattern: 'ntp server 10.0.0.1', + severity: 'medium', + selector: {}, + remediation: '', + enabled: true, + config_type: 'running', + failed: 0, + passed: 0, +} + +const probeDevices = { + devices: [ + { + id: 'd-1', + name: 'core-sw-01', + vendor: 'Cisco', + lines: 412, + collected_at: '2026-08-28T06:00:00Z', + }, + ], + vendors: ['Cisco', 'MikroTik'], +} + +const report = { + generated_at: '2026-08-28T10:00:00Z', + generated_by: 'ops', + last_check_at: '2026-08-28T09:30:00Z', + partial_scope: false, + rules_total: 2, + rules_enabled: 2, + rules_builtin: 1, + rules_never_run: 1, + devices: 6, + devices_failed: 6, + checks: 28, + failed: 18, + by_severity: [ + { severity: 'critical', count: 12 }, + { severity: 'high', count: 6 }, + { severity: 'medium', count: 0 }, + { severity: 'low', count: 0 }, + { severity: 'info', count: 0 }, + ], + rules: [ + { + name: builtinRule.name, + kind: builtinRule.kind, + pattern: builtinRule.pattern, + severity: 'critical', + config_type: 'running', + enabled: true, + remediation: builtinRule.remediation, + builtin_key: 'telnet-disabled', + checked: 6, + failed: 4, + }, + { + name: ownRule.name, + kind: ownRule.kind, + pattern: ownRule.pattern, + severity: 'medium', + config_type: 'running', + enabled: true, + checked: 0, + failed: 0, + }, + ], + violations: [ + { + device_name: 'core-sw-01', + rule_name: builtinRule.name, + severity: 'critical', + config_type: 'running', + line: 'transport input telnet ssh', + line_number: 11, + remediation: builtinRule.remediation, + builtin_key: 'telnet-disabled', + checked_at: '2026-08-28T09:30:00Z', + }, + ], +} + +function routes(over: Record = {}) { + return { + 'GET /api/v1/ncm/compliance/rules': { rules: [builtinRule, ownRule] }, + 'GET /api/v1/ncm/compliance/results': { results: [] }, + 'GET /api/v1/device-groups': { groups: [] }, + 'GET /api/v1/ncm/compliance/probe-devices': probeDevices, + 'GET /api/v1/ncm/compliance/report': report, + ...over, + } +} + +beforeEach(() => { + session.set('tok', { + userID: 'u-1', + username: 'ops', + tenantID: 't-1', + permissions: ['*'], + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function open() { + const r = fetchRouter(routes()) + render() + await screen.findByText('Telnet вимкнено') + return r +} + +// --------------------------------------------------------------------- +// 1. Що можна редагувати, а що ні +// --------------------------------------------------------------------- + +describe('вбудовані й свої правила', () => { + it('вбудоване: предмет перевірки замкнено, політика кабінету — ні', async () => { + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Налаштувати' })) + + // Пояснення стоїть ПЕРЕД сірими полями. Сірі поля без причини — + // це запрошення здогадуватись, а здогадка тут буде «система + // зламана». + expect(await screen.findByText(/вбудоване правило/i)).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /Зробити копію й правити її/ }), + ).toBeInTheDocument() + + // Пошук — усередині вікна: підпис «Назва» є ще й у шапці таблиці + // під ним, і глобальний пошук знайшов би два. + const form = screen.getByRole('dialog') + const value = (label: string) => + within(form).getByText(label).parentElement?.querySelector('input, select') + + expect(value('Назва')).toBeDisabled() + expect(value('Зразок')).toBeDisabled() + expect(value('Умова')).toBeDisabled() + expect(value('Що саме перевіряти')).toBeDisabled() + + // А це — вибір кабінету, і його ніхто не забирає. + expect(value('Важкість')).not.toBeDisabled() + expect(value('Як виправити')).not.toBeDisabled() + expect(value('Опис')).not.toBeDisabled() + }) + + it('своє правило правиться цілком — інакше замок нічого не доводить', async () => { + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Змінити' })) + await screen.findByText('Зразок') + + const form = screen.getByRole('dialog') + const value = (label: string) => + within(form).getByText(label).parentElement?.querySelector('input, select') + + expect(value('Назва')).not.toBeDisabled() + expect(value('Зразок')).not.toBeDisabled() + expect(value('Умова')).not.toBeDisabled() + expect(value('Що саме перевіряти')).not.toBeDisabled() + // І банера про вбудоване тут бути не мусить. + expect(screen.queryByRole('button', { name: /Зробити копію й правити її/ })).toBeNull() + }) + + it('вбудоване не видаляється, своє — видаляється', async () => { + await open() + + const del = screen.getAllByRole('button', { name: 'Видалити' }) + expect(del).toHaveLength(2) + // Порядок рядків — порядок правил у відповіді сервера. + expect(del[0]).toBeDisabled() + expect(del[1]).not.toBeDisabled() + // Причина відмови мусить бути читабельною, а не «просто сіре». + expect(del[0].getAttribute('title')).toMatch(/Вимкніть його/) + }) + + it('копія вбудованого — своє правило, вимкнене, без ключа', async () => { + const r = await open() + + fireEvent.click(screen.getAllByRole('button', { name: 'Копія' })[0]) + await screen.findByText('Нове правило') + + const form = screen.getByRole('dialog') + const value = (label: string) => + within(form).getByText(label).parentElement?.querySelector( + 'input, select', + ) as HTMLInputElement + + expect(value('Назва').value).toBe('Telnet вимкнено (копія)') + expect(value('Назва')).not.toBeDisabled() + expect(value('Зразок').value).toBe(builtinRule.pattern) + expect(value('Зразок')).not.toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Зберегти' })) + await waitFor(() => { + expect(r.calls.some((c) => c.method === 'POST')).toBe(true) + }) + const saved = r.calls.find((c) => c.method === 'POST') as Call + // POST без id — нове правило. Ключа вбудованого в тілі немає: + // копія не успадковує чужої гарантії за зразком. + expect(saved.url).toBe('/api/v1/ncm/compliance/rules') + expect((saved.body as Record).builtin_key).toBeUndefined() + // І вимкнена: увімкнена одразу, вона задвоїла б знахідки з + // оригіналом. + expect((saved.body as Record).enabled).toBe(false) + }) + + it('вимкнути вбудоване в себе — можна, і правило їде цілим', async () => { + const r = await open() + + // Перемикач стоїть у рядку: «вимкнути в себе» — головна дія над + // вбудованим правилом, і ховати її у форму означало б зробити її + // рідкісною. + const toggles = screen.getAllByRole('switch') + fireEvent.click(toggles[0]) + + await waitFor(() => { + expect(r.calls.some((c) => c.method === 'PUT')).toBe(true) + }) + const put = r.calls.find((c) => c.method === 'PUT') as Call + const body = put.body as Record + expect(body.enabled).toBe(false) + // Серверний замок звіряє назву, вид, зразок і тип конфігу з базою. + // Надіслати частину полів означало б затерти решту умовчаннями — і + // дістати відмову там, де людина просто клацнула перемикач. + expect(body.name).toBe(builtinRule.name) + expect(body.kind).toBe(builtinRule.kind) + expect(body.pattern).toBe(builtinRule.pattern) + expect(body.config_type).toBe(builtinRule.config_type) + }) + + it('відмову сервера видно, а не проковтнуто', async () => { + fetchRouter( + routes({ + 'PUT /api/v1/ncm/compliance/rules/r-telnet': () => + res(400, { + error: { + code: 'invalid', + message: 'зразок вбудованого правила (telnet-disabled) змінити не можна', + }, + }), + }), + ) + render() + await screen.findByText('Telnet вимкнено') + + fireEvent.click(screen.getAllByRole('switch')[0]) + expect(await screen.findByText(/змінити не можна/)).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------- +// 1a. Склад хостів переживає правку правила +// --------------------------------------------------------------------- + +/** + * ЩО ТУТ БОЛИТЬ. Форма редагує два виміри складу хостів — групи й + * вендорів. Селектор має більше: окремі хости, майданчики, типи, + * шаблони, мітки. Зібраний з нуля з двох полів, він МОВЧКИ викидає + * решту — і правило, звужене до трьох хостів, після натискання + * «Зберегти» починає стосуватись усього парку. + * + * Помилка тиха вдвічі: правило не падає й не попереджає, а для «має + * бути» ще й одразу сипле порушеннями на хостах, до яких ніколи не + * стосувалось, — і виглядає це як робота перевірки, а не як її поломка. + */ +describe('склад хостів правила', () => { + const narrowRule = { + ...ownRule, + id: 'r-narrow', + name: 'NTP на трьох хостах', + selector: { + device_ids: ['d-1', 'd-2', 'd-3'], + vendors: ['Cisco'], + // Вимір, якого форма не знає взагалі. + site_ids: ['s-kyiv'], + }, + } + + async function openNarrow() { + const r = fetchRouter( + routes({ 'GET /api/v1/ncm/compliance/rules': { rules: [narrowRule] } }), + ) + render() + await screen.findByText('NTP на трьох хостах') + fireEvent.click(screen.getByRole('button', { name: 'Змінити' })) + await screen.findByText('Зразок') + return r + } + + it('device_ids і решта невидимих вимірів переживають збереження', async () => { + const r = await openNarrow() + + fireEvent.click(screen.getByRole('button', { name: 'Зберегти' })) + await waitFor(() => { + expect(r.calls.some((c) => c.method === 'PUT')).toBe(true) + }) + + const put = r.calls.find((c) => c.method === 'PUT') as Call + const sel = (put.body as { selector: Record }).selector + expect(sel.device_ids).toEqual(['d-1', 'd-2', 'd-3']) + expect(sel.site_ids).toEqual(['s-kyiv']) + // Те, що форма редагує, лишається редагованим. + expect(sel.vendors).toEqual(['Cisco']) + }) + + it('про невидимі виміри сказано вголос — порожні поля не означають «усі хости»', async () => { + await openNarrow() + const note = screen.getByText(/Склад хостів цього правила звужений/) + expect(note).toBeInTheDocument() + expect(note.textContent).toMatch(/окремі хости/) + expect(note.textContent).toMatch(/майданчики/) + }) +}) + +// --------------------------------------------------------------------- +// 2. Перевірка зразка на живому конфізі +// --------------------------------------------------------------------- + +describe('перевірка зразка на справжньому конфізі', () => { + function probeResponse(matches: { line_number: number; line: string }[], passed: boolean) { + return { + device_id: 'd-1', + device_name: 'core-sw-01', + vendor: 'Cisco', + config_id: 'c-1', + config_type: 'running', + collected_at: '2026-08-28T06:00:00Z', + lines: 412, + no_config: false, + in_selector: true, + matches, + truncated: false, + passed, + } + } + + /** + * `which` — яке саме правило відкрити. Це не дрібниця: попередження + * про зразок, що не ловить нічого, різне для «має бути» і «не має + * бути», бо нуль збігів означає в них протилежне. + */ + async function openForm(probe: unknown, which: 'Змінити' | 'Налаштувати' = 'Змінити') { + const r = fetchRouter( + routes({ 'POST /api/v1/ncm/compliance/probe': probe }), + ) + render() + await screen.findByText('Telnet вимкнено') + fireEvent.click(screen.getByRole('button', { name: which })) + await screen.findByText('Перевірити на хості') + return r + } + + it('зразок, що ловить: видно рядки з номерами', async () => { + await openForm( + probeResponse([{ line_number: 11, line: 'transport input telnet ssh' }], false), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + + expect(await screen.findByText('transport input telnet ssh')).toBeInTheDocument() + // Номер рядка — те, що робить знахідку дією, а не натяком. + expect(screen.getByText('11')).toBeInTheDocument() + expect(screen.getByText(/1 збіг/)).toBeInTheDocument() + }) + + it('зразок, що не ловить НІКОЛИ: попередження, а не тихе зелене', async () => { + // Сервер каже passed: true — тобто «порушень немає». Рівно те, що + // сказало б і правильне правило на чистому хості. Форма мусить + // показати, що це РІЗНІ речі. + // Вбудоване «Telnet вимкнено» — правило виду «не має бути»: саме в + // ньому нуль збігів читається як «усе гаразд». + await openForm(probeResponse([], true), 'Налаштувати') + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + + expect(await screen.findByText(/0 збігів/)).toBeInTheDocument() + const warn = screen.getByText(/не наліг на жоден рядок/) + expect(warn).toBeInTheDocument() + expect(warn.textContent).toMatch(/те саме, що показало б правило з опискою/) + // Ніякої таблиці збігів: показувати нема чого, і порожня таблиця + // читалась би як «перевірено». + expect(screen.queryByText('що знайдено')).toBeNull() + }) + + it('«конфігу немає» — не те саме, що «зразок нічого не знайшов»', async () => { + await openForm({ + device_id: 'd-1', + device_name: 'core-sw-01', + config_type: 'running', + collected_at: '2026-08-28T06:00:00Z', + lines: 0, + no_config: true, + in_selector: true, + matches: [], + truncated: false, + passed: true, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + + expect(await screen.findByText(/не було чого читати/)).toBeInTheDocument() + // Найважливіше: тут НЕ мусить бути висновку про зразок. + expect(screen.queryByText(/не наліг на жоден рядок/)).toBeNull() + }) + + it('хост поза складом правила — сказано вголос', async () => { + const probe = probeResponse([{ line_number: 11, line: 'transport input telnet ssh' }], false) + await openForm({ ...probe, in_selector: false }) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + expect(await screen.findByText(/в прогоні цієї пари не буде/)).toBeInTheDocument() + }) + + it('перевірка йде ДО збереження й несе поточний зразок із поля', async () => { + const r = await openForm(probeResponse([], true)) + + const input = screen + .getByText('Зразок') + .parentElement?.querySelector('input') as HTMLInputElement + fireEvent.change(input, { target: { value: 'transport imput telnet' } }) + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + + await waitFor(() => { + expect(r.calls.some((c) => c.url === '/api/v1/ncm/compliance/probe')).toBe(true) + }) + const probe = r.calls.find((c) => c.url === '/api/v1/ncm/compliance/probe') as Call + const body = probe.body as { device_id: string; rule: Record } + expect(body.device_id).toBe('d-1') + expect(body.rule.pattern).toBe('transport imput telnet') + // І жодного збереження: правило ще не мусить поїхати в базу — + // збереження стирає результати попереднього прогону. + expect(r.calls.some((c) => c.method === 'PUT' || c.method === 'POST')).toBe( + r.calls.some((c) => c.url === '/api/v1/ncm/compliance/probe'), + ) + expect(r.calls.filter((c) => c.url.startsWith('/api/v1/ncm/compliance/rules')).length).toBe(1) + }) + + /** + * НАЙВАЖЛИВІШИЙ ТЕСТ ЦЬОГО РОЗДІЛУ. + * + * ПРИЧИНА. Результат унизу — доказ про КОНКРЕТНИЙ зразок. Скидання за + * хостом було з самого початку, за самим зразком — ні; а зразок + * правлять по разу на кожну спробу, тоді як хост міняють раз. + * + * НАСЛІДОК, якби доказ лишався: людина проганяє зразок, бачить «1 + * збіг», підправляє одну літеру, дивиться на той самий доказ і + * зберігає НЕПЕРЕВІРЕНИЙ зразок. Уся ця ручка існує рівно для того, + * щоб такого не сталося; старий результат на екрані перетворює її на + * протилежне — і саме в мить найвищої довіри до неї. + */ + it('правка зразка скидає показаний доказ', async () => { + await openForm( + probeResponse([{ line_number: 11, line: 'transport input telnet ssh' }], false), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + expect(await screen.findByText('transport input telnet ssh')).toBeInTheDocument() + expect(screen.getByText(/1 збіг/)).toBeInTheDocument() + + const input = screen + .getByText('Зразок') + .parentElement?.querySelector('input') as HTMLInputElement + fireEvent.change(input, { target: { value: 'transport imput telnet' } }) + + await waitFor(() => { + expect(screen.queryByText('transport input telnet ssh')).toBeNull() + }) + expect(screen.queryByText(/^\d+ збіг/)).toBeNull() + expect(screen.queryByText(/вислід прогону/)).toBeNull() + }) + + it('зміна умови скидає доказ — нуль збігів у ній означає протилежне', async () => { + await openForm( + probeResponse([{ line_number: 11, line: 'transport input telnet ssh' }], false), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + expect(await screen.findByText('transport input telnet ssh')).toBeInTheDocument() + + const form = screen.getByRole('dialog') + const kind = within(form) + .getByText('Умова') + .parentElement?.querySelector('select') as HTMLSelectElement + fireEvent.change(kind, { target: { value: 'must_not_contain' } }) + + await waitFor(() => { + expect(screen.queryByText('transport input telnet ssh')).toBeNull() + }) + }) + + it('зміна типу конфігу скидає доказ — читали б уже інший документ', async () => { + await openForm( + probeResponse([{ line_number: 11, line: 'transport input telnet ssh' }], false), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Перевірити зразок' })) + expect(await screen.findByText('transport input telnet ssh')).toBeInTheDocument() + + const form = screen.getByRole('dialog') + const cfg = within(form) + .getByText('Що саме перевіряти') + .parentElement?.querySelector('select') as HTMLSelectElement + fireEvent.change(cfg, { target: { value: 'startup' } }) + + await waitFor(() => { + expect(screen.queryByText('transport input telnet ssh')).toBeNull() + }) + }) + + it('вендорний селектор бере вендорів із живого інвентарю', async () => { + await openForm(probeResponse([], true)) + // Список вендорів приходить тією ж ручкою, що й хости для + // перевірки: вендор, якого в парку немає, дав би правило, що не + // перевіряє нічого. + expect(screen.getByText('Вендори')).toBeInTheDocument() + expect(screen.getByText(/мовчатиме про MikroTik/)).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------- +// 3. Звіт +// --------------------------------------------------------------------- + +describe('звіт', () => { + async function openReport(over: Record = {}) { + const r = fetchRouter(routes(over)) + render() + await screen.findByText('Telnet вимкнено') + fireEvent.click(screen.getByRole('button', { name: /^Звіт/ })) + await screen.findByText('Зведення') + return r + } + + it('шапка несе дату, автора й час останньої перевірки', async () => { + await openReport() + expect(screen.getByText('Сформовано:')).toBeInTheDocument() + expect(screen.getByText('Сформував:')).toBeInTheDocument() + expect(screen.getByText('ops')).toBeInTheDocument() + expect(screen.getByText('Остання перевірка:')).toBeInTheDocument() + }) + + it('склад правил у звіті — зі зразками й походженням', async () => { + await openReport() + // Без зразків склад правил не пояснює чисел: за місяць зразок + // змінять, і два роздруки з різними числами не буде чим пояснити. + expect(screen.getByText('Склад правил на момент звіту')).toBeInTheDocument() + expect(screen.getAllByText(builtinRule.pattern).length).toBeGreaterThan(0) + expect(screen.getByText(/вбудоване · telnet-disabled/)).toBeInTheDocument() + expect(screen.getByText('своє')).toBeInTheDocument() + }) + + it('правило без жодної перевірки видиме як таке, а не як зелене', async () => { + await openReport() + expect(screen.getByText('увімкнено, жодної перевірки')).toBeInTheDocument() + expect(screen.getByText(/не питали/)).toBeInTheDocument() + }) + + it('порушення йдуть із порадою', async () => { + await openReport() + const table = screen.getByText('Порушення').closest('div')?.parentElement as HTMLElement + expect(within(table).getByText('core-sw-01')).toBeInTheDocument() + expect(within(table).getByText(builtinRule.remediation)).toBeInTheDocument() + }) + + /** + * Два правила з однаковою назвою — і жоден рядок не зникає. + * + * ПРИЧИНА. Назва правила не унікальна: сервер не забороняє двом своїм + * правилам зватись однаково, бо назва — це підпис для людини, а не + * ідентифікатор. Ключі рядків за назвою робили з двох правил одне. + * + * НАСЛІДОК: у звіті, який кладуть аудитору, зникає РЯДОК ПОРУШЕННЯ, а + * число в шапці лишається правильним. Розбіжність «порушень 2, у + * таблиці 1» пояснити буде нічим. + * + * Перевіряємо і рядки, і попередження React: однакові ключі він + * пропускає з console.error, а на оновленні списку вже плутає рядки + * місцями. Мовчазний console.error — саме та форма, у якій ця помилка + * прожила б до аудиту. + */ + it('два правила з однаковою назвою не з’їдають одне одного', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const twin = { ...report.rules[1], name: report.rules[0].name, pattern: 'ntp' } + const twinViolation = { ...report.violations[0], rule_name: report.rules[0].name } + await openReport({ + 'GET /api/v1/ncm/compliance/report': { + ...report, + rules: [report.rules[0], twin], + violations: [report.violations[0], twinViolation], + failed: 2, + }, + }) + + expect(screen.getAllByText(report.rules[0].name).length).toBeGreaterThanOrEqual(3) + expect( + spy.mock.calls.some((c) => String(c[0]).includes('same key')), + ).toBe(false) + } finally { + spy.mockRestore() + } + }) + + it('обмежена видимість — застереження в звіті', async () => { + await openReport({ + 'GET /api/v1/ncm/compliance/report': { ...report, partial_scope: true }, + }) + expect(screen.getByText(/Ви бачите не весь парк/)).toBeInTheDocument() + }) + + it('перевірок не було — порожній перелік пояснює себе', async () => { + await openReport({ + 'GET /api/v1/ncm/compliance/report': { + ...report, + last_check_at: undefined, + checks: 0, + failed: 0, + devices: 0, + devices_failed: 0, + rules_never_run: 2, + by_severity: report.by_severity.map((s) => ({ ...s, count: 0 })), + violations: [], + rules: report.rules.map((r) => ({ ...r, checked: 0, failed: 0 })), + }, + }) + expect(screen.getByText(/нулі нижче означають «не питали»/)).toBeInTheDocument() + expect(screen.getByText(/Перевірок не було — це не «порушень немає»/)).toBeInTheDocument() + }) + + it('вивантаження CSV іде на сервер, а не збирається в браузері', async () => { + await openReport() + + // Кнопка тягне файл через fetch: посилання не вміє додати + // Authorization. Підміняємо лише те, чого немає в jsdom. + const blob = { type: 'text/csv' } as Blob + const csvRes = { + ok: true, + status: 200, + blob: async () => blob, + headers: { + get: (k: string) => + k === 'Content-Disposition' ? 'attachment; filename="compliance-2026-08-28-1000.csv"' : null, + }, + } as unknown as Response + + const seen: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + seen.push(url) + return csvRes + }), + ) + vi.stubGlobal('URL', { + createObjectURL: () => 'blob:x', + revokeObjectURL: () => {}, + }) + const click = vi.fn() + const anchor = { href: '', download: '', click } as unknown as HTMLAnchorElement + vi.spyOn(document, 'createElement').mockReturnValue(anchor) + + fireEvent.click(screen.getByRole('button', { name: 'Вивантажити CSV' })) + + await waitFor(() => expect(click).toHaveBeenCalled()) + expect(seen).toContain('/api/v1/ncm/compliance/report.csv') + // Ім'я файла — з відповіді сервера: дата в ньому потрібна, щоб + // десяток таких файлів у папці можна було розрізнити. + expect(anchor.download).toBe('compliance-2026-08-28-1000.csv') + vi.restoreAllMocks() + }) + + it('PDF немає — і сказано, чому', async () => { + await openReport() + expect(screen.queryByRole('button', { name: /PDF/ })).toBeNull() + expect(screen.getByText(/PDF немає навмисно/)).toBeInTheDocument() + }) +}) diff --git a/web/src/types.ts b/web/src/types.ts index 4e33ca8..9dc7625 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1335,7 +1335,7 @@ export interface ComplianceRule { kind: string pattern: string severity: string - selector: { group_ids?: string[]; device_ids?: string[] } + selector: { group_ids?: string[]; device_ids?: string[]; vendors?: string[] } remediation?: string enabled: boolean /** @@ -1346,11 +1346,118 @@ export interface ComplianceRule { * підпадає: правило про sshd_config не має що сказати про комутатор. */ config_type: string + /** + * Ключ вбудованого правила, з якого зроблено цю копію; порожньо — + * правило написала людина. + * + * За вбудованим зразком стоїть доказ: перевірка в + * `server/internal/store/ncm_compliance_builtin_test.go` показує для + * кожного конфіг, де він мусить спрацювати, і конфіг, де не мусить. + * Тому предмет перевірки (назва, вид, зразок, тип конфігу) у таких + * правил замкнений і на сервері, і у формі — переписаний руками + * зразок цього доказу не має, а значок «вбудоване» лишався б. + */ + builtin_key?: string /** Підсумок останньої перевірки. */ failed: number passed: number } +/** Хост, на якому можна перевірити зразок правила. */ +export interface ComplianceProbeDevice { + id: string + name: string + vendor?: string + model?: string + /** Рядків у найсвіжішому конфізі потрібного типу. */ + lines: number + collected_at: string +} + +export interface ComplianceProbeMatch { + line_number: number + line: string +} + +/** + * Що зробив зразок на справжньому конфізі. + * + * Головне поле тут — `matches`, а не `passed`. Правило зі зразком, що не + * збігається НІКОЛИ, дає `passed: true` — тобто виглядає бездоганно й не + * перевіряє нічого. Відрізнити його від справного можна лише за + * переліком рядків, на які зразок наліг. + */ +export interface ComplianceProbe { + device_id: string + device_name: string + vendor?: string + config_id?: string + config_type: string + collected_at: string + lines: number + /** У хоста немає конфігу цього типу — це не «зразок нічого не знайшов». */ + no_config: boolean + /** Чи підпадає хост під селектор правила. */ + in_selector: boolean + matches: ComplianceProbeMatch[] + truncated: boolean + passed: boolean +} + +/** Один рядок складу правил у шапці звіту. */ +export interface ComplianceReportRule { + name: string + kind: string + pattern: string + severity: string + config_type: string + enabled: boolean + remediation?: string + builtin_key?: string + checked: number + failed: number +} + +export interface ComplianceViolation { + device_name: string + rule_name: string + severity: string + config_type: string + line?: string + line_number?: number + remediation?: string + builtin_key?: string + checked_at: string +} + +/** + * Звіт про відповідність — те, що показують керівнику або аудитору. + * + * Склад правил у шапці обов'язковий: за місяць правила зміняться, і два + * роздруки з різними числами не буде чим пояснити. «18 порушень» у + * серпні й «4» у вересні — це або робота інженерів, або хтось вимкнув + * чотирнадцять правил, і за самими числами ці випадки не відрізняються. + */ +export interface ComplianceReport { + generated_at: string + generated_by?: string + /** Порожньо — перевірки не було жодної, і нулі означають «не питали». */ + last_check_at?: string + /** Той, хто сформував звіт, бачить не весь парк. */ + partial_scope: boolean + rules_total: number + rules_enabled: number + rules_builtin: number + rules_never_run: number + devices: number + devices_failed: number + checks: number + failed: number + by_severity: { severity: string; count: number }[] + rules: ComplianceReportRule[] + violations: ComplianceViolation[] +} + export interface ComplianceResult { rule_id: string rule_name: string