Перший справжній прогін на живій мережі дав 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. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
320 lines
15 KiB
Go
320 lines
15 KiB
Go
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
|
||
}
|