Netpulse_SasS/server/internal/httpapi/compliance.go
byrsapty 6de3565bbe
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m9s
CI / server (push) Successful in 1m56s
CI / agent (push) Successful in 2m56s
Відповідність: редагування правил, звіт і пʼять знахідок рецензії
Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.

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

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

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

1. Перше збереження будь-якого вбудованого правила стирало результати.
   Селектор порівнювався в базі, але порівнювались різні представлення
   одного значення: міграція кладе {}, Go марширує сім ключів із null.
   Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
   файл відкриває аудитор. Одне місце екранування на всі три звіти:
   дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
   рахувались як чинні. Три заслони: фільтр у списку, прибирання при
   прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
   інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
   число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
   ручка робила протилежне до задуманого в мить найвищої довіри.
2026-08-28 13:40:40 +03:00

340 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/store"
)
// Відповідність конфігів вимогам.
//
// Перевірка читає вже зібрані конфіги й не створює жодної сесії до
// заліза — тому прогін можна запускати скільки завгодно, і він нічого не
// коштує мережі. Через це ж він і синхронний: чекати на нього довелося б
// секунди, а не хвилини.
func (s *Server) handleListComplianceRules(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
rules, err := s.store.ListComplianceRules(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "правила відповідності", err)
return
}
// Лічильники «стільки з стількох не пройшли» рахуються ТУТ, із тих
// самих рядків, які людина побачить у «Знахідках», і після відсіву
// за видимістю.
//
// ПРИЧИНА: раніше їх давали два 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) {
if !requirePerm(w, p, "ncm:write") {
return
}
var in store.ComplianceRule
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
in.ID = r.PathValue("id")
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "правило без назви")
return
}
if in.Severity == "" {
in.Severity = "medium"
}
// Типово — конфіг заліза. Правило без явного типу писали під нього,
// і мовчки перевіряти ним набір файлів сервера означало б дати
// впевнену неправильну відповідь замість жодної.
if in.ConfigType == "" {
in.ConfigType = "running"
}
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
if err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
}
func (s *Server) handleDeleteComplianceRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
if err := s.store.DeleteComplianceRule(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListComplianceResults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
onlyFailed := r.URL.Query().Get("failed") == "1"
out, err := s.visibleComplianceResults(r, p, onlyFailed)
if err != nil {
s.writeStoreError(w, "результати перевірки", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": out})
}
func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — тіла конфігів не прочитати")
return
}
stat, err := s.store.RunCompliance(r.Context(), p.TenantID, s.keyring)
if err != nil {
s.writeStoreError(w, "перевірка відповідності", err)
return
}
// Прогін — єдина мить, коли відомі обидві половини правди:
// які хости порушили вимогу й на яких вона більше не порушується.
// Тому тригери джерела `compliance` обробляються рівно тут, а не
// окремим опитуванням таблиці результатів, яке однаково не змогло б
// відрізнити «стало погано» від «було погано вчора».
if s.events != nil && len(stat.Findings) > 0 {
evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings))
for _, f := range stat.Findings {
evs = append(evs, alerting.ComplianceEvent{
RuleID: f.RuleID, RuleName: f.RuleName, Severity: f.Severity,
DeviceID: f.DeviceID, Passed: f.Passed,
Line: f.Line, LineNumber: f.LineNumber,
})
}
s.events.OnCompliance(r.Context(), p.TenantID, evs)
}
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)
}
}