Netpulse_SasS/server/internal/httpapi/compliance.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

363 lines
17 KiB
Go
Raw Permalink 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 в обох випадках, і після нього відрізнити їх уже
// нічим.
existing := in.ID != ""
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
if err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
if existing {
s.audit(r, p, store.AuditActionComplianceRuleUpdate, store.AuditObjectComplianceRule, id,
complianceRuleAuditMeta(in))
} else {
s.audit(r, p, store.AuditActionComplianceRuleCreate, store.AuditObjectComplianceRule, id,
complianceRuleAuditMeta(in))
}
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
}
ref, err := s.store.DeleteComplianceRule(r.Context(), p.TenantID, r.PathValue("id"))
if err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
// Зразок їде в подробиці саме на видаленні: після нього вимоги
// немає ніде, і питання «а що ми взагалі перевіряли» лишилось би
// без відповіді назавжди. Та сама причина, що у видаленні ролі.
s.audit(r, p, store.AuditActionComplianceRuleDelete, store.AuditObjectComplianceRule, ref.ID,
map[string]any{"name": ref.Name, "severity": ref.Severity, "pattern": ref.Pattern})
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,
// Алерт народжується зі шкалою АЛЕРТІВ, а не
// відповідності: далі його читають маршрути, пороги
// каналів і драбини ескалації — усе, що знає лише
// info/warning/average/high/disaster.
Severity: store.ComplianceSeverityToAlert(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)
}
}