Відповідність: редагування правил, звіт і пʼять знахідок рецензії
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s

Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.

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

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

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

1. Перше збереження будь-якого вбудованого правила стирало результати.
   Селектор порівнювався в базі, але порівнювались різні представлення
   одного значення: міграція кладе {}, Go марширує сім ключів із null.
   Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
   файл відкриває аудитор. Одне місце екранування на всі три звіти:
   дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
   рахувались як чинні. Три заслони: фільтр у списку, прибирання при
   прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
   інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
   число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
This commit is contained in:
byrsapty 2026-08-28 13:40:40 +03:00
parent e6b585dd4c
commit 6de3565bbe
14 changed files with 5052 additions and 241 deletions

View file

@ -171,6 +171,94 @@ TimescaleDB рахує згортки), **закривається**: рахує
(початок періоду старший за збережену історію), `unknown_tz`, (початок періоду старший за збережену історію), `unknown_tz`,
`device_purged`. `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` — вхід ### `POST /api/v1/auth/login` — вхід
```jsonc ```jsonc

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/store" "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) s.writeStoreError(w, "правила відповідності", err)
return 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) { 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 return
} }
onlyFailed := r.URL.Query().Get("failed") == "1" 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 { if err != nil {
s.writeStoreError(w, "результати перевірки", err) s.writeStoreError(w, "результати перевірки", err)
return 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}) 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) 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)
}
}

View file

@ -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("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("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults))
mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance)) 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))
// Масове виконання команд. Перегляд переліку хостів — окремий крок // Масове виконання команд. Перегляд переліку хостів — окремий крок
// і окремий ендпоїнт: підтвердження без точного переліку не // і окремий ендпоїнт: підтвердження без точного переліку не

View file

@ -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() }

View file

@ -36,6 +36,19 @@ type ComplianceRule struct {
// попереджає, просто відповідає про інший документ. // попереджає, просто відповідає про інший документ.
ConfigType string `json:"config_type"` 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"` Failed int `json:"failed"`
Passed int `json:"passed"` Passed int `json:"passed"`
@ -63,7 +76,260 @@ var complianceKinds = map[string]bool{
"regex_match": true, "regex_absent": true, "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) { func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]ComplianceRule, error) {
out := []ComplianceRule{} out := []ComplianceRule{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { 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, SELECT r.id::text, r.name, COALESCE(r.description,''), r.kind::text,
r.pattern, r.severity::text, r.selector::text, r.pattern, r.severity::text, r.selector::text,
COALESCE(r.remediation,''), r.enabled, r.config_type, COALESCE(r.remediation,''), r.enabled, r.config_type,
(SELECT count(*) FROM ncm.compliance_results x COALESCE(r.builtin_key,'')
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)
FROM ncm.compliance_rules r FROM ncm.compliance_rules r
WHERE r.tenant_id = $1 WHERE r.tenant_id = $1
ORDER BY r.name ORDER BY r.name
@ -89,10 +352,10 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com
var sel string var sel string
if err := rows.Scan(&r.ID, &r.Name, &r.Description, &r.Kind, &r.Pattern, if err := rows.Scan(&r.ID, &r.Name, &r.Description, &r.Kind, &r.Pattern,
&r.Severity, &sel, &r.Remediation, &r.Enabled, &r.ConfigType, &r.Severity, &sel, &r.Remediation, &r.Enabled, &r.ConfigType,
&r.Failed, &r.Passed); err != nil { &r.BuiltinKey); err != nil {
return err 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) return fmt.Errorf("правило %s: selector: %w", r.Name, err)
} }
out = append(out, r) out = append(out, r)
@ -102,24 +365,53 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com
return out, err return out, err
} }
// SaveComplianceRule створює або оновлює правило. // ApplyComplianceCounts проставляє правилам Failed/Passed із тих самих
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) // ПРИЧИНА окремої функції, а не count(*) у запиті: підрахунок мусить
} // іти ПІСЛЯ відсіву за видимістю (Scope), який живе в обробнику HTTP.
if strings.TrimSpace(r.Pattern) == "" { // Порахований у SQL, він суперечив би переліку під собою — і суперечив
return "", fmt.Errorf("%w: правило без зразка нічого не перевіряє", ErrInvalid) // би тихо, бо кожне з двох чисел саме по собі виглядає правдоподібно.
} //
// Регулярний вираз компілюємо тут, а не під час перевірки: інакше // НАСЛІДОК: список правил і вкладка «Знахідки» не можуть розійтись у
// про друкарську помилку людина дізнається не з форми, а з правила, // принципі — вони рахують один і той самий зріз.
// яке мовчки нічого не знаходить. func ApplyComplianceCounts(rules []ComplianceRule, results []ComplianceResult) []ComplianceRule {
if r.Kind == "regex_match" || r.Kind == "regex_absent" { failed := map[string]int{}
if _, err := regexp.Compile(r.Pattern); err != nil { passed := map[string]int{}
return "", fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err) 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 { if err != nil {
return "", err 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) string(sel), r.Remediation, r.Enabled, r.ConfigType).Scan(&id)
} }
id = r.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, ` tag, err := tx.Exec(ctx, `
UPDATE ncm.compliance_rules SET UPDATE ncm.compliance_rules SET
name = $3, description = NULLIF($4,''), kind = $5::ncm.rule_kind, 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 { if tag.RowsAffected() == 0 {
return ErrNotFound 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 return id, err
} }
// DeleteComplianceRule видаляє СВОЄ правило.
//
// Вбудоване не видаляється, і це не педантизм. Видалене правило зникає
// зі списку — тобто кабінет, у якому хтось прибрав «Telnet вимкнено»,
// виглядає рівно так само, як кабінет, де telnet скрізь закритий: у
// звіті немає ні рядка про telnet, ні згадки, що його ніхто не питав.
// Вимкнене правило лишається видимим і потрапляє у звіт окремим
// розділом «вимкнені» — відсутність, яку видно, коштує дешевше за
// відсутність, якої не видно (та сама думка, що в шапці 0071 про
// порожній розділ і про вигляд покриття).
//
// Друга, дрібніша причина: ncm.builtin_compliance_apply() довкладає
// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось
// би при наступному накаті довідника — уже ввімкненим і без сліду, що
// його свідомо прибирали.
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error { func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) 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, tag, err := tx.Exec(ctx,
`DELETE FROM ncm.compliance_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID) `DELETE FROM ncm.compliance_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil { if err != nil {
@ -176,6 +541,27 @@ func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) e
} }
// ListComplianceResults — знахідки, спершу провалені й найважчі. // 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) { func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, onlyFailed bool) ([]ComplianceResult, error) {
out := []ComplianceResult{} out := []ComplianceResult{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { 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 ncm.compliance_rules cr ON cr.id = r.rule_id
JOIN inv.devices d ON d.id = r.device_id JOIN inv.devices d ON d.id = r.device_id
WHERE r.tenant_id = $1 AND ($2 = false OR NOT r.passed) 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, ORDER BY r.passed,
array_position(ARRAY['critical','high','medium','low','info'], array_position(ARRAY['critical','high','medium','low','info'],
cr.severity::text), 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) 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 var re *regexp.Regexp
if r.Kind == "regex_match" || r.Kind == "regex_absent" { if r.Kind == "regex_match" || r.Kind == "regex_absent" {
re, err = regexp.Compile(r.Pattern) re, err = regexp.Compile(r.Pattern)
@ -378,6 +781,24 @@ func (s *Store) complianceTargets(ctx context.Context, tenantID string, sel Sele
return out, err return out, err
} }
// pruneComplianceResults лишає в результатах правила лише ті хости, які
// під ним зараз.
//
// `<> ALL(порожній масив)` — істина, тож правило без жодного хоста
// втрачає всі свої результати. Це навмисно: правило, під яке не підпадає
// ніхто, не має чинних відповідей, і старі показували б мережу, якої
// воно вже не питає.
func (s *Store) pruneComplianceResults(ctx context.Context, tenantID, ruleID string, keep []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
DELETE FROM ncm.compliance_results
WHERE tenant_id = $1 AND rule_id = $2
AND device_id <> ALL($3::uuid[])
`, tenantID, ruleID, nonNilIDs(keep))
return err
})
}
func (s *Store) latestConfigBody(ctx context.Context, tenantID, deviceID, configType string, ring *crypto.Keyring) (string, string, error) { func (s *Store) latestConfigBody(ctx context.Context, tenantID, deviceID, configType string, ring *crypto.Keyring) (string, string, error) {
if configType == "" { if configType == "" {
configType = "running" configType = "running"

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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()
}

View file

@ -320,7 +320,12 @@ func writeCSVReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
return err return err
} }
cw := csv.NewWriter(w) // Через safeCSV: у клітинку `output` їде СИРИЙ вивід команди з
// пристрою, а в `command` — те, що набрала людина. Клітинка, яка
// починається з `=`, для Excel не текст, а формула, і виконає її та
// сама людина, яка відкрила звіт. Одне місце екранування на всі три
// наші csv — у csv_safe.go.
cw := newSafeCSV(csv.NewWriter(w))
if err := cw.Write([]string{ if err := cw.Write([]string{
"run_id", "host", "address", "status", "duration_ms", "run_id", "host", "address", "status", "duration_ms",
"started_at", "finished_at", "command", "prep", "output", "started_at", "finished_at", "command", "prep", "output",

View file

@ -48,10 +48,16 @@ func SLAReportCSV(w io.Writer, rep SLAReport) error {
return err return err
} }
cw := csv.NewWriter(w) raw := csv.NewWriter(w)
cw.Comma = ';' raw.Comma = ';'
// CRLF — те, чого чекає Excel. Для решти світу різниці немає. // CRLF — те, чого чекає Excel. Для решти світу різниці немає.
cw.UseCRLF = true raw.UseCRLF = true
// Через safeCSV: назва цілі й назви хостів приходять від людини, а
// клітинка, що починається з `=`, для Excel не текст, а формула.
// Пояснення повністю — у csv_safe.go; тут навмисно немає власного
// екранування, бо третій його примірник розійшовся б із двома
// іншими.
cw := newSafeCSV(raw)
// Шапка з умовами звіту. Вона й є те, що робить файл документом: // Шапка з умовами звіту. Вона й є те, що робить файл документом:
// без неї «99.912» не відповідає на питання «за що і проти чого». // без неї «99.912» не відповідає на питання «за що і проти чого».

View file

@ -53,6 +53,9 @@ import type {
InterfaceFilter, InterfaceFilter,
InterfaceFilterDevice, InterfaceFilterDevice,
InterfaceFilterPreview, InterfaceFilterPreview,
ComplianceProbe,
ComplianceProbeDevice,
ComplianceReport,
ComplianceResult, ComplianceResult,
ComplianceRule, ComplianceRule,
ComplianceStat, ComplianceStat,
@ -1226,6 +1229,84 @@ export const api = {
runCompliance: () => runCompliance: () =>
request<ComplianceStat>('/api/v1/ncm/compliance/run', { method: 'POST' }), request<ComplianceStat>('/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<ComplianceRule>) =>
request<ComplianceProbe>('/api/v1/ncm/compliance/probe', {
method: 'POST',
body: JSON.stringify({ device_id: deviceID, rule }),
}),
complianceReport: () =>
request<ComplianceReport>('/api/v1/ncm/compliance/report'),
/**
* Вивантаження звіту.
*
* CSV збирає сервер, а не браузер, з тієї ж причини, що й звіт SLA:
* саме цей файл кладуть у папку до аудиту й потім звіряють із тим, що
* показує сторінка. Два підрахунки одного й того самого розійдуться, і
* побачить це не той, хто помилився.
*
* Тягнемо через fetch, а не звичайним посиланням: посилання не вміє
* додати заголовок Authorization.
*/
downloadComplianceReport: async () => {
const headers: Record<string, string> = {}
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)
},
// --- масове виконання команд --- // --- масове виконання команд ---
/** /**

File diff suppressed because it is too large Load diff

View file

@ -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<string, unknown> = {}) {
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(<CompliancePage />)
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<string, unknown>).builtin_key).toBeUndefined()
// І вимкнена: увімкнена одразу, вона задвоїла б знахідки з
// оригіналом.
expect((saved.body as Record<string, unknown>).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<string, unknown>
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(<CompliancePage />)
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(<CompliancePage />)
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<string, unknown> }).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(<CompliancePage />)
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<string, unknown> }
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<string, unknown> = {}) {
const r = fetchRouter(routes(over))
render(<CompliancePage />)
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()
})
})

View file

@ -1335,7 +1335,7 @@ export interface ComplianceRule {
kind: string kind: string
pattern: string pattern: string
severity: string severity: string
selector: { group_ids?: string[]; device_ids?: string[] } selector: { group_ids?: string[]; device_ids?: string[]; vendors?: string[] }
remediation?: string remediation?: string
enabled: boolean enabled: boolean
/** /**
@ -1346,11 +1346,118 @@ export interface ComplianceRule {
* підпадає: правило про sshd_config не має що сказати про комутатор. * підпадає: правило про sshd_config не має що сказати про комутатор.
*/ */
config_type: string config_type: string
/**
* Ключ вбудованого правила, з якого зроблено цю копію; порожньо
* правило написала людина.
*
* За вбудованим зразком стоїть доказ: перевірка в
* `server/internal/store/ncm_compliance_builtin_test.go` показує для
* кожного конфіг, де він мусить спрацювати, і конфіг, де не мусить.
* Тому предмет перевірки (назва, вид, зразок, тип конфігу) у таких
* правил замкнений і на сервері, і у формі переписаний руками
* зразок цього доказу не має, а значок «вбудоване» лишався б.
*/
builtin_key?: string
/** Підсумок останньої перевірки. */ /** Підсумок останньої перевірки. */
failed: number failed: number
passed: 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 { export interface ComplianceResult {
rule_id: string rule_id: string
rule_name: string rule_name: string