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