Netpulse_SasS/server/internal/store/ncm_compliance_probe.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

320 lines
15 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 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
}