Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 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:
parent
e6b585dd4c
commit
6de3565bbe
14 changed files with 5052 additions and 241 deletions
|
|
@ -171,6 +171,94 @@ TimescaleDB рахує згортки), **закривається**: рахує
|
|||
(початок періоду старший за збережену історію), `unknown_tz`,
|
||||
`device_purged`.
|
||||
|
||||
### Відповідність конфігів
|
||||
|
||||
| Метод | Шлях | Призначення |
|
||||
|-------|------|-------------|
|
||||
| `GET` | `/api/v1/ncm/compliance/rules` | правила з підсумком останньої перевірки (`ncm:read`) |
|
||||
| `POST` | `/api/v1/ncm/compliance/rules` | створити СВОЄ правило (`ncm:write`) |
|
||||
| `PUT` | `/api/v1/ncm/compliance/rules/{id}` | змінити правило (`ncm:write`) |
|
||||
| `DELETE` | `/api/v1/ncm/compliance/rules/{id}` | видалити СВОЄ правило (`ncm:write`) |
|
||||
| `GET` | `/api/v1/ncm/compliance/results` | знахідки; `?failed=1` — лише порушення (`ncm:read`) |
|
||||
| `POST` | `/api/v1/ncm/compliance/run` | прогін по вже зібраних конфігах (`ncm:read`) |
|
||||
| `GET` | `/api/v1/ncm/compliance/probe-devices` | на чому перевірити зразок + вендори парку; `?config_type=` (`ncm:read`) |
|
||||
| `POST` | `/api/v1/ncm/compliance/probe` | **прогнати незбережений зразок** по конфігу одного хоста (`ncm:read`) |
|
||||
| `GET` | `/api/v1/ncm/compliance/report` | звіт: зведення, склад правил, порушення з порадами (`ncm:read`) |
|
||||
| `GET` | `/api/v1/ncm/compliance/report.csv` | те саме вивантаженням (`ncm:read`) |
|
||||
|
||||
**Вбудовані правила.** Кабінет отримує ВЛАСНУ копію кожного правила з
|
||||
довідника `ncm.builtin_compliance` (0071); копію впізнають за
|
||||
`builtin_key`. У таких правил замкнено предмет перевірки — `name`,
|
||||
`kind`, `pattern`, `config_type`: за вбудованим зразком стоїть перевірка
|
||||
на справжніх конфігах кількох вендорів, а переписаний руками зразок
|
||||
цього доказу вже не має й лишався б позначеним як вбудований. Спроба
|
||||
змінити замкнене поле — `400 invalid`. Решта полів (`enabled`,
|
||||
`severity`, `selector`, `description`, `remediation`) — політика
|
||||
кабінету, і вона відкрита. Видалення вбудованого правила — теж
|
||||
`400 invalid`: вимкнене правило лишається видимим у звіті, видалене —
|
||||
робить кабінет схожим на той, де вимога виконана.
|
||||
|
||||
**Чому зразок перевіряють окремою ручкою.** Регулярний вираз, який
|
||||
компілюється й не збігається НІКОЛИ, дає «порушень немає» — тобто
|
||||
виглядає бездоганно й не перевіряє нічого; за результатом прогону його
|
||||
не відрізнити від справного правила. `POST .../probe` віддає РЯДКИ, на
|
||||
які зразок наліг, із номерами:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"device_name": "core-sw-01",
|
||||
"config_type": "running",
|
||||
"collected_at": "2026-08-28T06:00:00Z",
|
||||
"lines": 412,
|
||||
"no_config": false, // конфігу цього типу немає — це НЕ «нічого не знайшов»
|
||||
"in_selector": true, // хост підпадає під склад правила
|
||||
"matches": [ { "line_number": 11, "line": "transport input telnet ssh" } ],
|
||||
"truncated": false, // збігів більше за стелю (200)
|
||||
"passed": false // той самий висновок, що зробить прогін
|
||||
}
|
||||
```
|
||||
|
||||
**Зразок не може містити переносу рядка.** Перевірка йде порядково, тож
|
||||
зразок із `\n` або `\r` не збігся б НІКОЛИ — а для `must_not_contain` і
|
||||
`regex_absent` це вічне тихе «пройдено». Відмова — `400 invalid`.
|
||||
|
||||
Збереження правила стирає його результати ЛИШЕ тоді, коли змінився
|
||||
предмет перевірки (вид, зразок, тип конфігу, селектор). Вимкнення,
|
||||
перейменування, інша серйозність чи порада картину прогону не чіпають.
|
||||
Селектор порівнюється за ЗНАЧЕННЯМ і зберігається в канонічній формі
|
||||
(порожні виміри не пишуться): `{}`, `{"vendors":[]}` і селектор із
|
||||
`null` у всіх ключах — це один і той самий «до всіх», і правка правила
|
||||
через них результатів не стирає.
|
||||
|
||||
**Що вважається чинною знахідкою.** `/results` і звіт віддають лише
|
||||
результати УВІМКНЕНИХ правил і лише по хостах, які не видалено. Вимкнули
|
||||
правило — його старі знахідки перестають рахуватись у «Порушень N»
|
||||
(самé правило лишається у звіті поіменно як вимкнене, і рядки в базі
|
||||
теж лишаються: увімкніть і прожену́ть — картина повернеться). Прогін
|
||||
додатково прибирає результати по хостах, які випали із селектора
|
||||
правила: `/run` — єдина мить, коли повний склад чинних питань відомий.
|
||||
|
||||
**Лічильники `failed`/`passed`** у `/rules` рахуються з тих самих рядків,
|
||||
які людина отримує в `/results` — тобто після відсіву за видимістю. Два
|
||||
числа не можуть розійтись: це одне число. Перелік вендорів у
|
||||
`/probe-devices` теж обмежений видимістю — це агрегат про склад парку.
|
||||
|
||||
**CSV.** Клітинка, що починається з `=`, `+`, `-`, `@`, таба чи CR,
|
||||
екранується апострофом: у звіт їде сирий рядок конфігу пристрою, а Excel
|
||||
прочитав би його як формулу й виконав на машині аудитора. Числа
|
||||
(`-5`, `+12`) не чіпаються. Останній рядок файла — `КІНЕЦЬ ЗВІТУ` з
|
||||
кількістю порушень: відповідь іде потоком уже після `200 OK`, тож
|
||||
обірваний файл інакше не відрізнити від повного.
|
||||
|
||||
**Звіт** несе дату, автора, час останньої перевірки, зведення за
|
||||
серйозністю — і СКЛАД ПРАВИЛ на момент звіту зі зразками й походженням.
|
||||
Без складу правил два роздруки з різними числами нічим пояснити: «18
|
||||
порушень» і «4» — це або робота інженерів, або хтось вимкнув
|
||||
чотирнадцять правил. Окремо позначено правила, які не дали жодної
|
||||
перевірки (`rules_never_run`) і обмежену видимість автора
|
||||
(`partial_scope`). PDF немає: базові шрифти PDF не мають кирилиці, і
|
||||
звіт без вкладеного шрифту вийшов би тихо зіпсованим.
|
||||
|
||||
### `POST /api/v1/auth/login` — вхід
|
||||
|
||||
```jsonc
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
|
|
@ -25,7 +26,49 @@ func (s *Server) handleListComplianceRules(w http.ResponseWriter, r *http.Reques
|
|||
s.writeStoreError(w, "правила відповідності", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
|
||||
// Лічильники «стільки з стількох не пройшли» рахуються ТУТ, із тих
|
||||
// самих рядків, які людина побачить у «Знахідках», і після відсіву
|
||||
// за видимістю.
|
||||
//
|
||||
// ПРИЧИНА: раніше їх давали два count(*) у запиті правил — по всіх
|
||||
// хостах кабінету, без Scope. Інженер філії читав у списку «5 з 12
|
||||
// не пройшли», відкривав знахідки й бачив дві. Гірше за розбіжність
|
||||
// те, що агрегат сам по собі розповідав про хости, яких людині
|
||||
// бачити не належить.
|
||||
//
|
||||
// НАСЛІДОК: два числа не можуть розійтись у принципі — це одне
|
||||
// число, пораховане один раз.
|
||||
results, err := s.visibleComplianceResults(r, p, false)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "результати перевірки", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rules": store.ApplyComplianceCounts(rules, results),
|
||||
})
|
||||
}
|
||||
|
||||
// visibleComplianceResults — знахідки, з яких прибрано хости поза
|
||||
// видимістю людини.
|
||||
//
|
||||
// Одним місцем на всі три ручки (список правил, знахідки, звіт)
|
||||
// навмисно: три копії цього циклу розійшлися б на першій правці, і
|
||||
// розійшлись би тихо — кожне з чисел окремо виглядає правдоподібно.
|
||||
func (s *Server) visibleComplianceResults(r *http.Request, p *Principal, onlyFailed bool) ([]store.ComplianceResult, error) {
|
||||
res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Хости поза видимістю людини не показуємо: перевірка конфігів не
|
||||
// має бути обхідним шляхом до інвентарю чужої філії.
|
||||
scope := p.Scope()
|
||||
out := make([]store.ComplianceResult, 0, len(res))
|
||||
for _, x := range res {
|
||||
if scope.CanRead(x.DeviceID) {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
|
|
@ -77,21 +120,11 @@ func (s *Server) handleListComplianceResults(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
onlyFailed := r.URL.Query().Get("failed") == "1"
|
||||
res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed)
|
||||
out, err := s.visibleComplianceResults(r, p, onlyFailed)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "результати перевірки", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Хости поза видимістю людини не показуємо: перевірка конфігів не
|
||||
// має бути обхідним шляхом до інвентарю чужої філії.
|
||||
scope := p.Scope()
|
||||
out := make([]store.ComplianceResult, 0, len(res))
|
||||
for _, x := range res {
|
||||
if scope.CanRead(x.DeviceID) {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": out})
|
||||
}
|
||||
|
||||
|
|
@ -129,3 +162,179 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p *
|
|||
|
||||
writeJSON(w, http.StatusOK, stat)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Перевірка зразка на справжньому конфізі
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// Окремі ручки, а не поле у відповіді збереження: зразок перевіряють ДО
|
||||
// збереження й багато разів поспіль — по разу на кожну правку. Змусити
|
||||
// зберігати правило, щоб побачити наслідок, означало б не лише
|
||||
// застосувати неперевірений зразок до всього парку, а й СТЕРТИ картину
|
||||
// попереднього прогону (SaveComplianceRule чистить результати правила).
|
||||
// Тобто ціна однієї спроби «а якщо так?» була б у втраченій відповіді на
|
||||
// питання «а як зараз».
|
||||
//
|
||||
// Взірець — POST /api/v1/templates/interface-preview: та сама думка про
|
||||
// зразок, застосований наосліп.
|
||||
|
||||
// handleComplianceProbeDevices — на чому можна перевірити зразок.
|
||||
func (s *Server) handleComplianceProbeDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
configType := r.URL.Query().Get("config_type")
|
||||
list, err := s.store.ListComplianceProbeDevices(r.Context(), p.TenantID, configType)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "хости з конфігами", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Хости поза видимістю прибираємо тут, а не в SQL: правило доступу
|
||||
// живе в Scope, і другий його примірник у запиті розійшовся б із
|
||||
// першим на найближчій правці.
|
||||
scope := p.Scope()
|
||||
out := make([]store.ComplianceProbeDevice, 0, len(list))
|
||||
for _, d := range list {
|
||||
if scope.CanRead(d.ID) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Вендори тією ж ручкою: селектор вендорів і вибір хоста для
|
||||
// перевірки — одне питання «що в мене взагалі стоїть», і зібрані
|
||||
// двома запитами в різні секунди відповіді суперечили б одна одній.
|
||||
// Scope їде в запит: перелік вендорів — агрегат про склад парку, і
|
||||
// зібраний по всьому кабінету він розповів би інженерові філії про
|
||||
// залізо, якого йому бачити не належить.
|
||||
vendors, err := s.store.ListComplianceVendors(r.Context(), p.TenantID, scope)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "вендори", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"devices": out, "vendors": vendors})
|
||||
}
|
||||
|
||||
// handleComplianceProbe проганяє незбережений зразок по одному хосту.
|
||||
func (s *Server) handleComplianceProbe(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
// ncm:read, а не ncm:write. Перевірка нічого не змінює, і вимагати
|
||||
// на неї право запису означало б, що людина без такого права бачить
|
||||
// у звіті зразки правил і не має способу зрозуміти, що вони роблять.
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
if s.keyring == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_keyring",
|
||||
"сервер запущено без ключа шифрування — тіла конфігів не прочитати")
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Rule store.ComplianceRule `json:"rule"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||
return
|
||||
}
|
||||
if in.DeviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "не вказано хост для перевірки")
|
||||
return
|
||||
}
|
||||
if !p.Scope().CanRead(in.DeviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
if in.Rule.Severity == "" {
|
||||
in.Rule.Severity = "medium"
|
||||
}
|
||||
if in.Rule.ConfigType == "" {
|
||||
in.Rule.ConfigType = "running"
|
||||
}
|
||||
|
||||
// Помилку в зразку віддаємо як 400 з текстом RE2: людина зараз
|
||||
// дивиться на поле, у якому її зробила, і «внутрішня помилка»
|
||||
// відправила б її шукати проблему деінде.
|
||||
res, err := s.store.ProbeComplianceRule(r.Context(), p.TenantID, in.DeviceID, in.Rule, s.keyring)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перевірка зразка правила", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Звіт
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// complianceReport збирає звіт із того, що видно ЦІЙ людині.
|
||||
//
|
||||
// Спільний для JSON і CSV навмисно: два описи одного звіту розійшлися б
|
||||
// на першій правці, і сторінка почала б показувати не те, що лежить у
|
||||
// вивантаженому файлі. А звіряють їх саме так — файл проти екрана.
|
||||
func (s *Server) complianceReport(r *http.Request, p *Principal) (store.ComplianceReport, error) {
|
||||
rules, err := s.store.ListComplianceRules(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
return store.ComplianceReport{}, err
|
||||
}
|
||||
// Усі результати, і пройдені теж: «18 порушень» без знаменника не
|
||||
// означає нічого — це 18 із 28 чи 18 із 2800.
|
||||
visible, err := s.visibleComplianceResults(r, p, false)
|
||||
if err != nil {
|
||||
return store.ComplianceReport{}, err
|
||||
}
|
||||
scope := p.Scope()
|
||||
|
||||
who := p.Username
|
||||
if who == "" {
|
||||
who = p.TokenName
|
||||
}
|
||||
// Обмежена видимість їде у звіт полем, а не примітками в коді.
|
||||
// Інженер філії вивантажить правду про свої хости — і документ, який
|
||||
// виглядає як правда про всю мережу.
|
||||
return store.ComplianceReportOf(time.Now(), who, !scope.Unrestricted, rules, visible), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleComplianceReport(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
rep, err := s.complianceReport(r, p)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "звіт відповідності", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handleComplianceReportCSV — той самий звіт вивантаженням.
|
||||
//
|
||||
// Окремий обробник, а не параметр формату в попередньому: відповідь тут
|
||||
// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт
|
||||
// має вгадувати, як її читати. Квиток завантаження (0038) не потрібен:
|
||||
// звіт — це десятки кілобайтів, а не десятки мегабайтів.
|
||||
//
|
||||
// PDF тут немає, і це рішення, а не недоробка. Пояснення — у
|
||||
// web/src/pages/CompliancePage.tsx поруч із кнопкою, бо читає його той,
|
||||
// хто цю кнопку шукає.
|
||||
func (s *Server) handleComplianceReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
rep, err := s.complianceReport(r, p)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "звіт відповідності", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+store.ComplianceReportFileName(rep)+`"`)
|
||||
// Кешувати не можна: за тим самим шляхом наступного разу лежатиме
|
||||
// звіт про інший стан мережі, а розрізнити їх кешу нема чим.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := store.ComplianceReportCSV(w, rep); err != nil {
|
||||
// Відповідь уже пішла — статус не змінити. Лишається журнал.
|
||||
s.log.Error("вивантаження звіту відповідності", "err", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,15 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule))
|
||||
mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults))
|
||||
mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance))
|
||||
// Перевірка зразка на справжньому конфізі — до збереження правила.
|
||||
// Без неї редактор правил є генератором тихої брехні: зразок, який
|
||||
// не збігається ніколи, дає «порушень немає».
|
||||
mux.Handle("GET /api/v1/ncm/compliance/probe-devices", s.authenticated(s.handleComplianceProbeDevices))
|
||||
mux.Handle("POST /api/v1/ncm/compliance/probe", s.authenticated(s.handleComplianceProbe))
|
||||
// Звіт. Два шляхи, бо два типи вмісту: .csv віддає файл, і плутати
|
||||
// його з JSON в одному шляху означає, що клієнт має вгадувати.
|
||||
mux.Handle("GET /api/v1/ncm/compliance/report", s.authenticated(s.handleComplianceReport))
|
||||
mux.Handle("GET /api/v1/ncm/compliance/report.csv", s.authenticated(s.handleComplianceReportCSV))
|
||||
|
||||
// Масове виконання команд. Перегляд переліку хостів — окремий крок
|
||||
// і окремий ендпоїнт: підтвердження без точного переліку не
|
||||
|
|
|
|||
97
server/internal/store/csv_safe.go
Normal file
97
server/internal/store/csv_safe.go
Normal 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() }
|
||||
|
|
@ -36,6 +36,19 @@ type ComplianceRule struct {
|
|||
// попереджає, просто відповідає про інший документ.
|
||||
ConfigType string `json:"config_type"`
|
||||
|
||||
// BuiltinKey — ключ довідника ncm.builtin_compliance, з якого
|
||||
// зроблено цю копію; порожньо — правило написала людина.
|
||||
//
|
||||
// Поле віддається клієнтові навмисно, і не заради значка. Вбудоване
|
||||
// правило має за спиною перевірку (ncm_compliance_builtin_test.go):
|
||||
// його зразок доведено спрацював хоч на одному справжньому конфізі й
|
||||
// доведено не спрацював хоч на одному іншому. Правка зразка руками
|
||||
// цю гарантію знімає МОВЧКИ — правило лишається впізнаваним як
|
||||
// вбудоване й починає означати щось інше. Тому предмет перевірки
|
||||
// вбудованого правила замкнено (див. checkBuiltinImmutable), а
|
||||
// клієнт має знати, що саме йому показувати сірим і чому.
|
||||
BuiltinKey string `json:"builtin_key,omitempty"`
|
||||
|
||||
// Скільки хостів під правилом не пройшли перевірку — для списку.
|
||||
Failed int `json:"failed"`
|
||||
Passed int `json:"passed"`
|
||||
|
|
@ -63,7 +76,260 @@ var complianceKinds = map[string]bool{
|
|||
"regex_match": true, "regex_absent": true,
|
||||
}
|
||||
|
||||
// ListComplianceRules — правила разом із підсумком останньої перевірки.
|
||||
// Серйозності — рівно значення ncm.rule_severity з 0006.
|
||||
//
|
||||
// Перевіряються тут, а не покладаються на приведення `$6::ncm.rule_severity`
|
||||
// у SQL: база на невідомому значенні дасть 22P02 і 500 «внутрішня
|
||||
// помилка», тобто відправить людину шукати поломку сервера замість
|
||||
// друкарської помилки у власній формі.
|
||||
var complianceSeverities = map[string]bool{
|
||||
"info": true, "low": true, "medium": true, "high": true, "critical": true,
|
||||
}
|
||||
|
||||
// Типи конфігу, до яких застосовне правило.
|
||||
//
|
||||
// Перелік закритий, і це найважливіша перевірка з усіх трьох. config_type
|
||||
// у базі — звичайний text (0056), тобто «runnning» з одним зайвим `n`
|
||||
// зберігається без єдиної скарги. Наслідок такої описки: жоден хост не
|
||||
// має конфігу цього типу, latestConfigBody нічого не знаходить, УСІ пари
|
||||
// йдуть у stat.Skipped — і правило показує «порушень немає». Рівно та
|
||||
// форма тихої брехні, від якої 0056 і рятувала: впевнена відповідь про
|
||||
// документ, якого ніхто не читав.
|
||||
var complianceConfigTypes = map[string]bool{
|
||||
"running": true, "startup": true, "files": true,
|
||||
}
|
||||
|
||||
// validateComplianceRule — усе, що можна сказати про правило, не
|
||||
// заглядаючи ні в базу, ні в конфіги.
|
||||
//
|
||||
// Окремою функцією навмисно: так її перевіряє звичайний тест без
|
||||
// Postgres, а не лише прогін проти бази, який на машині розробника
|
||||
// мовчки пропускається (NETPULSE_TEST_DSN).
|
||||
func validateComplianceRule(r ComplianceRule) error {
|
||||
if !complianceKinds[r.Kind] {
|
||||
return fmt.Errorf("%w: невідомий вид правила %q", ErrInvalid, r.Kind)
|
||||
}
|
||||
if !complianceSeverities[r.Severity] {
|
||||
return fmt.Errorf("%w: невідома серйозність %q", ErrInvalid, r.Severity)
|
||||
}
|
||||
if !complianceConfigTypes[r.ConfigType] {
|
||||
return fmt.Errorf("%w: невідомий тип конфігу %q — правило не підпало б до жодного хоста "+
|
||||
"й показувало б «порушень немає»", ErrInvalid, r.ConfigType)
|
||||
}
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return fmt.Errorf("%w: правило без зразка нічого не перевіряє", ErrInvalid)
|
||||
}
|
||||
// Перенос рядка у зразку — зразок, який не збіжиться НІКОЛИ.
|
||||
//
|
||||
// ПРИЧИНА: і checkCompliance, і probeMatches ідуть конфігом
|
||||
// ПОРЯДКОВО (strings.Split по "\n"), тобто жоден рядок, поданий на
|
||||
// перевірку, переносу вже не містить. Зразок із переносом не має
|
||||
// шансу збігтися з жодним із них.
|
||||
//
|
||||
// НАСЛІДОК, якби пропускали: для must_contain це вічне «порушення на
|
||||
// кожному хості» — помилка гучна, її помітять того ж дня. Для
|
||||
// must_not_contain і regex_absent — вічне тихе «пройдено», тобто
|
||||
// вигляд бездоганної мережі. Саме друга форма й коштує дорого.
|
||||
if strings.ContainsAny(r.Pattern, "\n\r") {
|
||||
return fmt.Errorf("%w: зразок містить перенос рядка — перевірка йде порядково, "+
|
||||
"тож такий зразок не збігся б ніколи й показував би «порушень немає»", ErrInvalid)
|
||||
}
|
||||
// Регулярний вираз компілюємо тут, а не під час перевірки: інакше
|
||||
// про друкарську помилку людина дізнається не з форми, а з правила,
|
||||
// яке мовчки нічого не знаходить.
|
||||
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
|
||||
if _, err := regexp.Compile(r.Pattern); err != nil {
|
||||
return fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// complianceRuleHead — та частина наявного правила, яку треба знати
|
||||
// ДО того, як його переписувати.
|
||||
type complianceRuleHead struct {
|
||||
BuiltinKey string
|
||||
Name string
|
||||
Kind string
|
||||
Pattern string
|
||||
ConfigType string
|
||||
// Selector — збережений селектор, уже РОЗІБРАНИЙ.
|
||||
//
|
||||
// Саме розібраний, а не текст і не прапорець «змінився» з бази.
|
||||
// Історія цього поля варта рядка, бо помилка тут коштувала картини
|
||||
// мережі.
|
||||
//
|
||||
// Спершу питання вирішувала база виразом
|
||||
// `selector IS DISTINCT FROM $n::jsonb`. Здогад був правильний —
|
||||
// jsonb порівнює за змістом, а не за формою запису, тож зайвий
|
||||
// пробіл чи інший порядок ключів зміною не вважались. Але
|
||||
// порівнювались два РІЗНІ описи того самого: міграція 0071 кладе
|
||||
// `'{}'::jsonb`, а Go марширує store.Selector без `omitempty` —
|
||||
// тобто завжди сім ключів зі значенням null. Для jsonb `{}` і
|
||||
// `{"device_ids":null,…}` — різні значення. Отже, перше ж збереження
|
||||
// вбудованого правила (вимкнути, знизити серйозність, дописати
|
||||
// пораду) вважалося зміною селектора й СТИРАЛО результати прогону.
|
||||
// Помилка одноразова на правило, тому непомітна; а стерта картина
|
||||
// виглядає точнісінько як «у вас усе добре».
|
||||
//
|
||||
// Тепер порівнюються ЗНАЧЕННЯ: обидва селектори зводяться до
|
||||
// канонічного вигляду (див. selectorJSON) і зіставляються там.
|
||||
// Той самий клас помилки не вилізе, коли Selector отримає нове
|
||||
// поле: канонізація перелічує поля явно, а порожні викидає.
|
||||
Selector Selector
|
||||
}
|
||||
|
||||
// selectorJSON — селектор у вигляді, що залежить лише від ЗНАЧЕННЯ.
|
||||
//
|
||||
// Порожні виміри до запису не потрапляють узагалі: «жодної групи» і
|
||||
// «поля немає» — це одне й те саме твердження («до всіх»), і різниця
|
||||
// між `null`, `[]` та відсутністю ключа не має означати різні селектори.
|
||||
// Ключі мапи json.Marshal упорядковує сам, тож текст виходить сталий.
|
||||
//
|
||||
// Результат їде і в порівняння, і В БАЗУ: так селектор нормалізується
|
||||
// при кожному збереженні, і окрема міграція для цього не потрібна —
|
||||
// порівняння більше не залежить від того, у якій формі рядок лежить.
|
||||
func selectorJSON(sel Selector) ([]byte, error) {
|
||||
m := map[string]any{}
|
||||
put := func(key string, v []string) {
|
||||
if len(v) > 0 {
|
||||
m[key] = v
|
||||
}
|
||||
}
|
||||
put("device_ids", sel.DeviceIDs)
|
||||
put("group_ids", sel.GroupIDs)
|
||||
put("site_ids", sel.SiteIDs)
|
||||
put("kinds", sel.Kinds)
|
||||
put("template_ids", sel.TemplateIDs)
|
||||
put("vendors", sel.Vendors)
|
||||
if len(sel.Tags) > 0 {
|
||||
m["tags"] = sel.Tags
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// selectorsEqual — чи це той самий склад хостів.
|
||||
func selectorsEqual(a, b Selector) bool {
|
||||
ja, err := selectorJSON(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
jb, err := selectorJSON(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// parseSelector читає селектор із того, що лежить у базі.
|
||||
//
|
||||
// Порожній текст — це «до всіх», а не помилка: колонка може бути NULL у
|
||||
// рядку, який завели поза API.
|
||||
func parseSelector(raw string) (Selector, error) {
|
||||
var sel Selector
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return sel, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &sel); err != nil {
|
||||
return sel, err
|
||||
}
|
||||
return sel, nil
|
||||
}
|
||||
|
||||
// complianceSubjectChanged — чи змінилось те, ПРО ЩО правило питає.
|
||||
//
|
||||
// Від цього залежить, чи стирати результати попереднього прогону. Досі
|
||||
// стиралися завжди, і причина була правильна: зразок міг змінитись, а
|
||||
// відповідь на питання, якого ніхто вже не ставить, гірша за її
|
||||
// відсутність.
|
||||
//
|
||||
// Але під «завжди» потрапило й вимкнення правила. «Вимкнути в себе» —
|
||||
// це головна дія над вбудованим правилом (0071, 1.1: клієнт, який
|
||||
// свідомо тримає telnet у технологічній мережі), і платити за неї
|
||||
// стертою картиною всієї мережі по цьому правилу немає за що: питання
|
||||
// не змінилось, змінилось лише те, чи його ставлять. Те саме з
|
||||
// перейменуванням, порадою «як виправити» й серйозністю — серйозність
|
||||
// узагалі читається з правила на льоту (ListComplianceResults бере
|
||||
// cr.severity джойном).
|
||||
//
|
||||
// Селектор сюди входить: він вирішує, ЯКІ хости під правилом, і після
|
||||
// звуження старі результати лишились би про хости, яких правило вже не
|
||||
// стосується.
|
||||
func complianceSubjectChanged(cur complianceRuleHead, next ComplianceRule) bool {
|
||||
return cur.Kind != next.Kind ||
|
||||
cur.Pattern != next.Pattern ||
|
||||
cur.ConfigType != next.ConfigType ||
|
||||
!selectorsEqual(cur.Selector, next.Selector)
|
||||
}
|
||||
|
||||
// checkBuiltinImmutable не дає переписати предмет перевірки вбудованого
|
||||
// правила.
|
||||
//
|
||||
// ЩО САМЕ ЗАМКНЕНО: назва, вид, зразок і тип конфігу — тобто питання,
|
||||
// яке правило ставить. ЩО ЛИШАЄТЬСЯ ВІДКРИТИМ: enabled, серйозність,
|
||||
// селектор, опис і порада «як виправити» — тобто політика кабінету
|
||||
// щодо цього питання. Розріз саме тут, а не деінде: 0071 завела копію
|
||||
// на кабінет рівно для того, щоб один клієнт вимагав SSH-only скрізь, а
|
||||
// інший свідомо тримав telnet у технологічній мережі. Це вибір про
|
||||
// ЗАСТОСУВАННЯ, і його ніхто не забирає.
|
||||
//
|
||||
// ПРИЧИНА замка. За кожним вбудованим зразком стоїть доказ:
|
||||
// ncm_compliance_builtin_test.go читає зразки з самої міграції й
|
||||
// показує для кожного конфіг, де він мусить спрацювати, і конфіг, де
|
||||
// не мусить. Переписаний руками зразок цього доказу не має, а значок
|
||||
// «вбудоване» лишається — і в звіті, який кладуть аудитору, рядок
|
||||
// «SNMP: типові community» означатиме вже щось інше, ніж написано в
|
||||
// довіднику. Помилка тиха вдвічі: правило зі зразком, що не збігається
|
||||
// ніколи, дає «порушень немає» — вигляд бездоганної мережі.
|
||||
//
|
||||
// НАСЛІДОК для людини: замість сірих полів без пояснення вона отримує
|
||||
// кнопку «Зробити копію» — своє правило з тим самим зразком, без ключа
|
||||
// й без чужої гарантії за спиною. Копію можна правити як завгодно.
|
||||
//
|
||||
// Це трохи вужче за те, що описує 0071 у 1.1 («людина може … підправити
|
||||
// зразок»): міграція говорила про те, що builtin_key переживає UPDATE, і
|
||||
// не про те, що редактор мусить такий UPDATE пропонувати. Ключ і далі
|
||||
// переживає правку — просто предмет перевірки тепер не серед того, що
|
||||
// правиться.
|
||||
func checkBuiltinImmutable(cur complianceRuleHead, next ComplianceRule) error {
|
||||
if cur.BuiltinKey == "" {
|
||||
return nil
|
||||
}
|
||||
locked := []struct {
|
||||
field string
|
||||
was string
|
||||
now string
|
||||
}{
|
||||
{"назву", cur.Name, next.Name},
|
||||
{"вид умови", cur.Kind, next.Kind},
|
||||
{"зразок", cur.Pattern, next.Pattern},
|
||||
{"тип конфігу", cur.ConfigType, next.ConfigType},
|
||||
}
|
||||
for _, f := range locked {
|
||||
if f.was != f.now {
|
||||
return fmt.Errorf(
|
||||
"%w: %s вбудованого правила (%s) змінити не можна — зробіть копію й правте її",
|
||||
ErrInvalid, f.field, cur.BuiltinKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListComplianceRules — самі правила, БЕЗ лічильників знахідок.
|
||||
//
|
||||
// Лічильники Failed/Passed раніше рахувались тут же двома підзапитами по
|
||||
// всій таблиці результатів — і в цьому була помилка, яку видно лише
|
||||
// поруч із сусіднім екраном. Підзапити не знали ні про Scope, ні про
|
||||
// видалені хости, тоді як «Знахідки» і звіт фільтруються і за тим, і за
|
||||
// тим. Інженер філії бачив у списку «5 з 12 не пройшли», відкривав
|
||||
// знахідки — там дві. Гірше за розбіжність те, що сам агрегат ГОВОРИТЬ
|
||||
// про хости, яких людині бачити не належить.
|
||||
//
|
||||
// Тому лічильники більше не приходять із SQL: їх рахує
|
||||
// ApplyComplianceCounts із тих самих рядків, які людина отримує в
|
||||
// «Знахідках». Так вони збігаються не завдяки двом однаковим фільтрам, а
|
||||
// тому, що це одне й те саме число. Та сама думка, що й у
|
||||
// ComplianceReportOf, і з тієї ж причини.
|
||||
func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]ComplianceRule, error) {
|
||||
out := []ComplianceRule{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
|
|
@ -71,10 +337,7 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com
|
|||
SELECT r.id::text, r.name, COALESCE(r.description,''), r.kind::text,
|
||||
r.pattern, r.severity::text, r.selector::text,
|
||||
COALESCE(r.remediation,''), r.enabled, r.config_type,
|
||||
(SELECT count(*) FROM ncm.compliance_results x
|
||||
WHERE x.rule_id = r.id AND NOT x.passed),
|
||||
(SELECT count(*) FROM ncm.compliance_results x
|
||||
WHERE x.rule_id = r.id AND x.passed)
|
||||
COALESCE(r.builtin_key,'')
|
||||
FROM ncm.compliance_rules r
|
||||
WHERE r.tenant_id = $1
|
||||
ORDER BY r.name
|
||||
|
|
@ -89,10 +352,10 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com
|
|||
var sel string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Description, &r.Kind, &r.Pattern,
|
||||
&r.Severity, &sel, &r.Remediation, &r.Enabled, &r.ConfigType,
|
||||
&r.Failed, &r.Passed); err != nil {
|
||||
&r.BuiltinKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil {
|
||||
if r.Selector, err = parseSelector(sel); err != nil {
|
||||
return fmt.Errorf("правило %s: selector: %w", r.Name, err)
|
||||
}
|
||||
out = append(out, r)
|
||||
|
|
@ -102,24 +365,53 @@ func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]Com
|
|||
return out, err
|
||||
}
|
||||
|
||||
// SaveComplianceRule створює або оновлює правило.
|
||||
func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r ComplianceRule) (string, error) {
|
||||
if !complianceKinds[r.Kind] {
|
||||
return "", fmt.Errorf("%w: невідомий вид правила %q", ErrInvalid, r.Kind)
|
||||
}
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return "", fmt.Errorf("%w: правило без зразка нічого не перевіряє", ErrInvalid)
|
||||
}
|
||||
// Регулярний вираз компілюємо тут, а не під час перевірки: інакше
|
||||
// про друкарську помилку людина дізнається не з форми, а з правила,
|
||||
// яке мовчки нічого не знаходить.
|
||||
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
|
||||
if _, err := regexp.Compile(r.Pattern); err != nil {
|
||||
return "", fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err)
|
||||
// ApplyComplianceCounts проставляє правилам Failed/Passed із тих самих
|
||||
// результатів, які бачить людина.
|
||||
//
|
||||
// ПРИЧИНА окремої функції, а не count(*) у запиті: підрахунок мусить
|
||||
// іти ПІСЛЯ відсіву за видимістю (Scope), який живе в обробнику HTTP.
|
||||
// Порахований у SQL, він суперечив би переліку під собою — і суперечив
|
||||
// би тихо, бо кожне з двох чисел саме по собі виглядає правдоподібно.
|
||||
//
|
||||
// НАСЛІДОК: список правил і вкладка «Знахідки» не можуть розійтись у
|
||||
// принципі — вони рахують один і той самий зріз.
|
||||
func ApplyComplianceCounts(rules []ComplianceRule, results []ComplianceResult) []ComplianceRule {
|
||||
failed := map[string]int{}
|
||||
passed := map[string]int{}
|
||||
for _, x := range results {
|
||||
if x.Passed {
|
||||
passed[x.RuleID]++
|
||||
} else {
|
||||
failed[x.RuleID]++
|
||||
}
|
||||
}
|
||||
out := make([]ComplianceRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
r.Failed = failed[r.ID]
|
||||
r.Passed = passed[r.ID]
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
sel, err := json.Marshal(r.Selector)
|
||||
// SaveComplianceRule створює або оновлює правило.
|
||||
//
|
||||
// Нове правило ЗАВЖДИ своє: builtin_key у INSERT не перелічений, тобто
|
||||
// лишається NULL. Копія вбудованого правила («зробити копію й правити
|
||||
// її») приходить сюди тим самим шляхом, що й правило, набране з нуля, —
|
||||
// і саме тому вона не успадковує ані ключа, ані чужої гарантії за
|
||||
// зразком.
|
||||
func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r ComplianceRule) (string, error) {
|
||||
if err := validateComplianceRule(r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Селектор пишемо в КАНОНІЧНІЙ формі, а не через json.Marshal самої
|
||||
// структури. Причина й наслідок — у коментарі до complianceRuleHead.
|
||||
// Коротко: Selector не має `omitempty`, тож звичайний Marshal завжди
|
||||
// дає сім ключів із null, а міграція 0071 кладе `{}` — для jsonb це
|
||||
// різні значення того самого селектора.
|
||||
sel, err := selectorJSON(r.Selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -138,6 +430,42 @@ func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r Compl
|
|||
string(sel), r.Remediation, r.Enabled, r.ConfigType).Scan(&id)
|
||||
}
|
||||
id = r.ID
|
||||
|
||||
// Читаємо поточний стан ПІД БЛОКУВАННЯМ, у тій самій транзакції,
|
||||
// що й UPDATE. Перевірити замок окремим запитом до транзакції
|
||||
// означало б лишити щілину: між перевіркою й записом правило
|
||||
// може стати вбудованим (накат 0071 на кабінет, створений
|
||||
// раніше) — і саме той UPDATE, який замок мусив спинити,
|
||||
// пройшов би.
|
||||
var cur complianceRuleHead
|
||||
var curSel string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(builtin_key,''), name, kind::text, pattern, config_type,
|
||||
selector::text
|
||||
FROM ncm.compliance_rules
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
FOR UPDATE
|
||||
`, r.ID, tenantID).Scan(&cur.BuiltinKey, &cur.Name, &cur.Kind, &cur.Pattern,
|
||||
&cur.ConfigType, &curSel)
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Селектор порівнюємо за ЗНАЧЕННЯМ, у Go. База вміє порівнювати
|
||||
// jsonb за змістом, але зміст двох різних описів того самого
|
||||
// селектора для неї різний: `{}` (0071) проти семи ключів із
|
||||
// null (json.Marshal без omitempty). Саме на цьому перше
|
||||
// збереження будь-якого вбудованого правила мовчки стирало
|
||||
// результати прогону.
|
||||
if cur.Selector, err = parseSelector(curSel); err != nil {
|
||||
return fmt.Errorf("правило %s: збережений selector: %w", cur.Name, err)
|
||||
}
|
||||
if err := checkBuiltinImmutable(cur, r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE ncm.compliance_rules SET
|
||||
name = $3, description = NULLIF($4,''), kind = $5::ncm.rule_kind,
|
||||
|
|
@ -152,17 +480,54 @@ func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r Compl
|
|||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Зразок міг змінитись — старі результати більше ні про що не
|
||||
// свідчать, і лишати їх означає показувати відповідь на питання,
|
||||
// якого вже ніхто не ставив.
|
||||
_, err = tx.Exec(ctx, `DELETE FROM ncm.compliance_results WHERE rule_id = $1`, r.ID)
|
||||
return err
|
||||
// Результати стираємо, лише якщо змінився ПРЕДМЕТ перевірки:
|
||||
// старі відповіді на питання, якого вже ніхто не ставить, гірші
|
||||
// за їх відсутність. Вимкнення, перейменування, інша серйозність
|
||||
// чи інша порада питання не змінюють — і платити за них стертою
|
||||
// картиною мережі немає за що (див. complianceSubjectChanged).
|
||||
if complianceSubjectChanged(cur, r) {
|
||||
_, err = tx.Exec(ctx, `DELETE FROM ncm.compliance_results WHERE rule_id = $1`, r.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteComplianceRule видаляє СВОЄ правило.
|
||||
//
|
||||
// Вбудоване не видаляється, і це не педантизм. Видалене правило зникає
|
||||
// зі списку — тобто кабінет, у якому хтось прибрав «Telnet вимкнено»,
|
||||
// виглядає рівно так само, як кабінет, де telnet скрізь закритий: у
|
||||
// звіті немає ні рядка про telnet, ні згадки, що його ніхто не питав.
|
||||
// Вимкнене правило лишається видимим і потрапляє у звіт окремим
|
||||
// розділом «вимкнені» — відсутність, яку видно, коштує дешевше за
|
||||
// відсутність, якої не видно (та сама думка, що в шапці 0071 про
|
||||
// порожній розділ і про вигляд покриття).
|
||||
//
|
||||
// Друга, дрібніша причина: ncm.builtin_compliance_apply() довкладає
|
||||
// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось
|
||||
// би при наступному накаті довідника — уже ввімкненим і без сліду, що
|
||||
// його свідомо прибирали.
|
||||
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var builtin string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(builtin_key,'') FROM ncm.compliance_rules
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
FOR UPDATE
|
||||
`, id, tenantID).Scan(&builtin)
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if builtin != "" {
|
||||
return fmt.Errorf("%w: вбудоване правило (%s) не видаляється — вимкніть його, "+
|
||||
"щоб у звіті лишився слід, що вимогу знято свідомо", ErrInvalid, builtin)
|
||||
}
|
||||
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM ncm.compliance_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
|
|
@ -176,6 +541,27 @@ func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) e
|
|||
}
|
||||
|
||||
// ListComplianceResults — знахідки, спершу провалені й найважчі.
|
||||
//
|
||||
// ТІЛЬКИ ЧИННІ ВІДПОВІДІ. Два фільтри тут не косметичні:
|
||||
//
|
||||
// - `cr.enabled` — результати ВИМКНЕНОГО правила не віддаються. Прогін
|
||||
// вимкнене правило пропускає й старих його результатів не чіпає
|
||||
// (і правильно робить: вимкнути вбудоване правило «в себе» — головна
|
||||
// дія над ним, і платити за неї стертою картиною мережі немає за що,
|
||||
// див. complianceSubjectChanged). Але доки ці рядки віддавалися
|
||||
// нарівні з рештою, вимкнене правило рахувалось у «Порушень N»
|
||||
// назавжди — тобто підсумок відповідав на питання, якого вже ніхто
|
||||
// не ставить. Рядки лишаються в базі; повернути правило й прогнати
|
||||
// заново — і картина повертається.
|
||||
//
|
||||
// - `d.deleted_at IS NULL` — хост, якого видалили, більше не відповідач.
|
||||
// complianceTargets його вже не бере, тож нових результатів у нього
|
||||
// не з'явиться, а старі висіли б вічно. Саме м'яке видалення, а не
|
||||
// фізичне: фізичне забирає рядок каскадом по FK.
|
||||
//
|
||||
// НАСЛІДОК для звіту: ComplianceReportOf рахує підсумок із цієї ж
|
||||
// вибірки, тож «Порушень N» більше не містить відповідей на зняті
|
||||
// питання.
|
||||
func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, onlyFailed bool) ([]ComplianceResult, error) {
|
||||
out := []ComplianceResult{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
|
|
@ -189,6 +575,8 @@ func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, only
|
|||
JOIN ncm.compliance_rules cr ON cr.id = r.rule_id
|
||||
JOIN inv.devices d ON d.id = r.device_id
|
||||
WHERE r.tenant_id = $1 AND ($2 = false OR NOT r.passed)
|
||||
AND cr.enabled
|
||||
AND d.deleted_at IS NULL
|
||||
ORDER BY r.passed,
|
||||
array_position(ARRAY['critical','high','medium','low','info'],
|
||||
cr.severity::text),
|
||||
|
|
@ -273,6 +661,21 @@ func (s *Store) RunCompliance(ctx context.Context, tenantID string, ring *crypto
|
|||
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
|
||||
}
|
||||
|
||||
// Прибираємо відповіді про хости, яких правило ВЖЕ не стосується:
|
||||
// вони випали із селектора або їх видалили з інвентарю.
|
||||
//
|
||||
// Прогін — єдина мить, коли повний склад чинних питань відомий, і
|
||||
// тому чистка стоїть саме тут, а не в SaveComplianceRule: селектор
|
||||
// міг лишитись тим самим, а склад хостів під ним — змінитись
|
||||
// (хост вийшов із групи, у хоста змінили вендора, хост видалили).
|
||||
//
|
||||
// НАСЛІДОК, якби не чистили: правило, звужене до однієї групи,
|
||||
// й далі показувало б знахідки по всьому парку — і кожна з них
|
||||
// виглядала б як чинна.
|
||||
if err := s.pruneComplianceResults(ctx, tenantID, r.ID, devices); err != nil {
|
||||
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
|
||||
}
|
||||
|
||||
var re *regexp.Regexp
|
||||
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
|
||||
re, err = regexp.Compile(r.Pattern)
|
||||
|
|
@ -378,6 +781,24 @@ func (s *Store) complianceTargets(ctx context.Context, tenantID string, sel Sele
|
|||
return out, err
|
||||
}
|
||||
|
||||
// pruneComplianceResults лишає в результатах правила лише ті хости, які
|
||||
// під ним зараз.
|
||||
//
|
||||
// `<> ALL(порожній масив)` — істина, тож правило без жодного хоста
|
||||
// втрачає всі свої результати. Це навмисно: правило, під яке не підпадає
|
||||
// ніхто, не має чинних відповідей, і старі показували б мережу, якої
|
||||
// воно вже не питає.
|
||||
func (s *Store) pruneComplianceResults(ctx context.Context, tenantID, ruleID string, keep []string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
DELETE FROM ncm.compliance_results
|
||||
WHERE tenant_id = $1 AND rule_id = $2
|
||||
AND device_id <> ALL($3::uuid[])
|
||||
`, tenantID, ruleID, nonNilIDs(keep))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) latestConfigBody(ctx context.Context, tenantID, deviceID, configType string, ring *crypto.Keyring) (string, string, error) {
|
||||
if configType == "" {
|
||||
configType = "running"
|
||||
|
|
|
|||
1244
server/internal/store/ncm_compliance_edit_test.go
Normal file
1244
server/internal/store/ncm_compliance_edit_test.go
Normal file
File diff suppressed because it is too large
Load diff
320
server/internal/store/ncm_compliance_probe.go
Normal file
320
server/internal/store/ncm_compliance_probe.go
Normal 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
|
||||
}
|
||||
563
server/internal/store/ncm_compliance_report.go
Normal file
563
server/internal/store/ncm_compliance_report.go
Normal 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()
|
||||
}
|
||||
|
|
@ -320,7 +320,12 @@ func writeCSVReport(w *bufio.Writer, run CommandRun, rows pgx.Rows) error {
|
|||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
// Через safeCSV: у клітинку `output` їде СИРИЙ вивід команди з
|
||||
// пристрою, а в `command` — те, що набрала людина. Клітинка, яка
|
||||
// починається з `=`, для Excel не текст, а формула, і виконає її та
|
||||
// сама людина, яка відкрила звіт. Одне місце екранування на всі три
|
||||
// наші csv — у csv_safe.go.
|
||||
cw := newSafeCSV(csv.NewWriter(w))
|
||||
if err := cw.Write([]string{
|
||||
"run_id", "host", "address", "status", "duration_ms",
|
||||
"started_at", "finished_at", "command", "prep", "output",
|
||||
|
|
|
|||
|
|
@ -48,10 +48,16 @@ func SLAReportCSV(w io.Writer, rep SLAReport) error {
|
|||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Comma = ';'
|
||||
raw := csv.NewWriter(w)
|
||||
raw.Comma = ';'
|
||||
// CRLF — те, чого чекає Excel. Для решти світу різниці немає.
|
||||
cw.UseCRLF = true
|
||||
raw.UseCRLF = true
|
||||
// Через safeCSV: назва цілі й назви хостів приходять від людини, а
|
||||
// клітинка, що починається з `=`, для Excel не текст, а формула.
|
||||
// Пояснення повністю — у csv_safe.go; тут навмисно немає власного
|
||||
// екранування, бо третій його примірник розійшовся б із двома
|
||||
// іншими.
|
||||
cw := newSafeCSV(raw)
|
||||
|
||||
// Шапка з умовами звіту. Вона й є те, що робить файл документом:
|
||||
// без неї «99.912» не відповідає на питання «за що і проти чого».
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ import type {
|
|||
InterfaceFilter,
|
||||
InterfaceFilterDevice,
|
||||
InterfaceFilterPreview,
|
||||
ComplianceProbe,
|
||||
ComplianceProbeDevice,
|
||||
ComplianceReport,
|
||||
ComplianceResult,
|
||||
ComplianceRule,
|
||||
ComplianceStat,
|
||||
|
|
@ -1226,6 +1229,84 @@ export const api = {
|
|||
runCompliance: () =>
|
||||
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
723
web/src/test/compliance.test.tsx
Normal file
723
web/src/test/compliance.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
109
web/src/types.ts
109
web/src/types.ts
|
|
@ -1335,7 +1335,7 @@ export interface ComplianceRule {
|
|||
kind: string
|
||||
pattern: string
|
||||
severity: string
|
||||
selector: { group_ids?: string[]; device_ids?: string[] }
|
||||
selector: { group_ids?: string[]; device_ids?: string[]; vendors?: string[] }
|
||||
remediation?: string
|
||||
enabled: boolean
|
||||
/**
|
||||
|
|
@ -1346,11 +1346,118 @@ export interface ComplianceRule {
|
|||
* підпадає: правило про sshd_config не має що сказати про комутатор.
|
||||
*/
|
||||
config_type: string
|
||||
/**
|
||||
* Ключ вбудованого правила, з якого зроблено цю копію; порожньо —
|
||||
* правило написала людина.
|
||||
*
|
||||
* За вбудованим зразком стоїть доказ: перевірка в
|
||||
* `server/internal/store/ncm_compliance_builtin_test.go` показує для
|
||||
* кожного конфіг, де він мусить спрацювати, і конфіг, де не мусить.
|
||||
* Тому предмет перевірки (назва, вид, зразок, тип конфігу) у таких
|
||||
* правил замкнений і на сервері, і у формі — переписаний руками
|
||||
* зразок цього доказу не має, а значок «вбудоване» лишався б.
|
||||
*/
|
||||
builtin_key?: string
|
||||
/** Підсумок останньої перевірки. */
|
||||
failed: number
|
||||
passed: number
|
||||
}
|
||||
|
||||
/** Хост, на якому можна перевірити зразок правила. */
|
||||
export interface ComplianceProbeDevice {
|
||||
id: string
|
||||
name: string
|
||||
vendor?: string
|
||||
model?: string
|
||||
/** Рядків у найсвіжішому конфізі потрібного типу. */
|
||||
lines: number
|
||||
collected_at: string
|
||||
}
|
||||
|
||||
export interface ComplianceProbeMatch {
|
||||
line_number: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Що зробив зразок на справжньому конфізі.
|
||||
*
|
||||
* Головне поле тут — `matches`, а не `passed`. Правило зі зразком, що не
|
||||
* збігається НІКОЛИ, дає `passed: true` — тобто виглядає бездоганно й не
|
||||
* перевіряє нічого. Відрізнити його від справного можна лише за
|
||||
* переліком рядків, на які зразок наліг.
|
||||
*/
|
||||
export interface ComplianceProbe {
|
||||
device_id: string
|
||||
device_name: string
|
||||
vendor?: string
|
||||
config_id?: string
|
||||
config_type: string
|
||||
collected_at: string
|
||||
lines: number
|
||||
/** У хоста немає конфігу цього типу — це не «зразок нічого не знайшов». */
|
||||
no_config: boolean
|
||||
/** Чи підпадає хост під селектор правила. */
|
||||
in_selector: boolean
|
||||
matches: ComplianceProbeMatch[]
|
||||
truncated: boolean
|
||||
passed: boolean
|
||||
}
|
||||
|
||||
/** Один рядок складу правил у шапці звіту. */
|
||||
export interface ComplianceReportRule {
|
||||
name: string
|
||||
kind: string
|
||||
pattern: string
|
||||
severity: string
|
||||
config_type: string
|
||||
enabled: boolean
|
||||
remediation?: string
|
||||
builtin_key?: string
|
||||
checked: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export interface ComplianceViolation {
|
||||
device_name: string
|
||||
rule_name: string
|
||||
severity: string
|
||||
config_type: string
|
||||
line?: string
|
||||
line_number?: number
|
||||
remediation?: string
|
||||
builtin_key?: string
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Звіт про відповідність — те, що показують керівнику або аудитору.
|
||||
*
|
||||
* Склад правил у шапці обов'язковий: за місяць правила зміняться, і два
|
||||
* роздруки з різними числами не буде чим пояснити. «18 порушень» у
|
||||
* серпні й «4» у вересні — це або робота інженерів, або хтось вимкнув
|
||||
* чотирнадцять правил, і за самими числами ці випадки не відрізняються.
|
||||
*/
|
||||
export interface ComplianceReport {
|
||||
generated_at: string
|
||||
generated_by?: string
|
||||
/** Порожньо — перевірки не було жодної, і нулі означають «не питали». */
|
||||
last_check_at?: string
|
||||
/** Той, хто сформував звіт, бачить не весь парк. */
|
||||
partial_scope: boolean
|
||||
rules_total: number
|
||||
rules_enabled: number
|
||||
rules_builtin: number
|
||||
rules_never_run: number
|
||||
devices: number
|
||||
devices_failed: number
|
||||
checks: number
|
||||
failed: number
|
||||
by_severity: { severity: string; count: number }[]
|
||||
rules: ComplianceReportRule[]
|
||||
violations: ComplianceViolation[]
|
||||
}
|
||||
|
||||
export interface ComplianceResult {
|
||||
rule_id: string
|
||||
rule_name: string
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue