Перший справжній прогін на живій мережі дав 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. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
1244 lines
60 KiB
Go
1244 lines
60 KiB
Go
package store
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"reflect"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Редагування правил відповідності та звіт.
|
||
//
|
||
// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ — і чого він НЕ доводить, написано в кінці, у
|
||
// TestNothingHereProvesTheWholeChain. Коротко головне:
|
||
//
|
||
// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що
|
||
// вона перевіряє. Тест ізоляції RLS був правильний і зелений — і
|
||
// пропустив зламаний вхід, бо перевіряв «чи не видно чужого», коли
|
||
// зламалось «чи видно своє».
|
||
//
|
||
// Тут та сама пастка стоїть в іншому місці. Природний тест правила
|
||
// відповідності — «зразок збігся, порушення знайшлось». Він зелений і
|
||
// нічого не вартий сам по собі: правило зі зразком, що НЕ збігається
|
||
// ніколи, теж дає зелений екран — «порушень немає». Тому кожна
|
||
// перевірка нижче йде парою: зразок, що ловить, і зразок, що не ловить
|
||
// НІКОЛИ, — і другий мусить лишити слід, який видно.
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Взірець конфігу
|
||
// ---------------------------------------------------------------------
|
||
|
||
// Уривок справжнього вигляду `show running-config` — рівно те, на чому
|
||
// знайшлись 18 порушень першого прогону на мережі клієнта: типові
|
||
// community, telnet на керуванні, пароль зворотним шифром, доступ до
|
||
// керування без списку.
|
||
const sampleConfig = `!
|
||
hostname core-sw-01
|
||
!
|
||
enable secret 5 $1$abcd$0123456789ABCDEFGH
|
||
username admin password 7 070C285F4D06
|
||
!
|
||
snmp-server community public RO
|
||
snmp-server community private RW
|
||
!
|
||
line vty 0 4
|
||
transport input telnet ssh
|
||
login local
|
||
!
|
||
ip http server
|
||
!
|
||
end`
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 1. Висновок правила: пара «ловить» / «не ловить ніколи»
|
||
// ---------------------------------------------------------------------
|
||
|
||
func TestCheckComplianceVerdict(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
kind string
|
||
// pattern — зразок; matches каже, чи він узагалі щось знаходить
|
||
// у sampleConfig. Це поле — не дублювання очікування, а
|
||
// відповідь на питання «який саме випадок ми зараз перевіряємо»:
|
||
// половина рядків нижче існує лише заради matches=false.
|
||
pattern string
|
||
matches bool
|
||
// wantPassed — вислід, який побачить прогін.
|
||
wantPassed bool
|
||
// wantLine — чи буде що показати людині. Для «має бути» знахідка
|
||
// порожня навмисно: проблема саме у відсутності рядка.
|
||
wantLine string
|
||
wantNum int
|
||
}{
|
||
{
|
||
name: "не має містити: telnet знайдено",
|
||
kind: "must_not_contain",
|
||
pattern: "transport input telnet",
|
||
matches: true,
|
||
wantPassed: false,
|
||
wantLine: "transport input telnet ssh",
|
||
wantNum: 11,
|
||
},
|
||
{
|
||
name: "не має містити: зразок не збігається НІКОЛИ",
|
||
// Друкарська помилка на один символ: `imput` замість `input`.
|
||
// Правило компілюється, зберігається, проганяється — і
|
||
// повідомляє «порушень немає» на конфізі, де telnet
|
||
// увімкнений. Оце й є тиха брехня, заради якої існує
|
||
// перевірка зразка на живому конфізі.
|
||
kind: "must_not_contain",
|
||
pattern: "transport imput telnet",
|
||
matches: false,
|
||
wantPassed: true,
|
||
wantLine: "",
|
||
wantNum: 0,
|
||
},
|
||
{
|
||
name: "має містити: рядок є",
|
||
kind: "must_contain",
|
||
pattern: "enable secret",
|
||
matches: true,
|
||
wantPassed: true,
|
||
wantLine: "enable secret 5 $1$abcd$0123456789ABCDEFGH",
|
||
wantNum: 4,
|
||
},
|
||
{
|
||
name: "має містити: зразок не збігається НІКОЛИ",
|
||
// Тут порожній зразок бʼє в інший бік: правило «має бути»
|
||
// з непотрапляючим зразком показує ПОРУШЕННЯ на кожному
|
||
// хості. Помилка гучна — її помітять того ж дня. Саме
|
||
// тому вона й безпечніша за попередню.
|
||
kind: "must_contain",
|
||
pattern: "ntp server 10.0.0.1",
|
||
matches: false,
|
||
wantPassed: false,
|
||
wantLine: "",
|
||
wantNum: 0,
|
||
},
|
||
{
|
||
name: "збіг за виразом: знайдено",
|
||
kind: "regex_match",
|
||
pattern: `^\s*login local\s*$`,
|
||
matches: true,
|
||
wantPassed: true,
|
||
wantLine: "login local",
|
||
wantNum: 12,
|
||
},
|
||
{
|
||
name: "немає збігу: пароль зворотним шифром",
|
||
kind: "regex_absent",
|
||
// Тип 7 у Cisco — не шифр, а кодування: розкручується в один
|
||
// рядок будь-яким онлайн-декодером.
|
||
pattern: `password\s+7\s+[0-9A-Fa-f]+`,
|
||
matches: true,
|
||
wantPassed: false,
|
||
wantLine: "username admin password 7 070C285F4D06",
|
||
wantNum: 5,
|
||
},
|
||
{
|
||
name: "немає збігу: вираз, який не збіжиться ніколи",
|
||
// `\bpassword\b` після `^` без пробілів: у конфізі рядок
|
||
// починається з `username`, тож зразок не наліг НІ НА ЩО.
|
||
// І правило показує «пройдено».
|
||
kind: "regex_absent",
|
||
pattern: `^password\s+7\s`,
|
||
matches: false,
|
||
wantPassed: true,
|
||
wantLine: "",
|
||
wantNum: 0,
|
||
},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
r := ComplianceRule{
|
||
Name: c.name, Kind: c.kind, Pattern: c.pattern,
|
||
Severity: "high", ConfigType: "running", Enabled: true,
|
||
}
|
||
if err := validateComplianceRule(r); err != nil {
|
||
t.Fatalf("правило мало бути коректним: %v", err)
|
||
}
|
||
|
||
var re *regexp.Regexp
|
||
if strings.HasPrefix(c.kind, "regex") {
|
||
re = regexp.MustCompile(c.pattern)
|
||
}
|
||
|
||
passed, line, num := checkCompliance(r, re, sampleConfig)
|
||
if passed != c.wantPassed {
|
||
t.Errorf("вислід = %v, очікували %v", passed, c.wantPassed)
|
||
}
|
||
if line != c.wantLine {
|
||
t.Errorf("рядок = %q, очікували %q", line, c.wantLine)
|
||
}
|
||
if num != c.wantNum {
|
||
t.Errorf("номер рядка = %d, очікували %d", num, c.wantNum)
|
||
}
|
||
|
||
// ГОЛОВНЕ ТУТ. Вислід сам по собі не відрізняє «зразок
|
||
// перевірив і не знайшов» від «зразок не має що перевіряти».
|
||
// Відрізняє перелік збігів, який показує форма.
|
||
got, _ := probeMatches(r, re, sampleConfig)
|
||
if c.matches && len(got) == 0 {
|
||
t.Errorf("зразок %q не наліг у формі на жоден рядок — людина побачила б "+
|
||
"порожній перелік і мусила б здогадуватись", c.pattern)
|
||
}
|
||
if !c.matches && len(got) != 0 {
|
||
t.Errorf("зразок %q мав не знайти нічого, а знайшов %d рядків", c.pattern, len(got))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestProbeShowsNeverMatchingPattern — та сама пара, але поставлена як
|
||
// питання до ФОРМИ, а не до прогону.
|
||
//
|
||
// Прогін на обох цих правилах дає однакову відповідь: «порушень немає».
|
||
// Якби форма показувала лише її, редактор правил був би генератором
|
||
// тихої брехні. Тест фіксує, що форма показує РІЗНЕ.
|
||
func TestProbeShowsNeverMatchingPattern(t *testing.T) {
|
||
good := ComplianceRule{
|
||
Kind: "must_not_contain", Pattern: "transport input telnet",
|
||
Severity: "critical", ConfigType: "running",
|
||
}
|
||
typo := good
|
||
typo.Pattern = "transport imput telnet" // одна літера
|
||
|
||
goodPassed, _, _ := checkCompliance(good, nil, sampleConfig)
|
||
typoPassed, _, _ := checkCompliance(typo, nil, sampleConfig)
|
||
if goodPassed {
|
||
t.Fatal("правильне правило мало знайти telnet")
|
||
}
|
||
if !typoPassed {
|
||
t.Fatal("правило з опискою мало показати «пройдено» — саме в цьому й біда")
|
||
}
|
||
|
||
goodHits, _ := probeMatches(good, nil, sampleConfig)
|
||
typoHits, _ := probeMatches(typo, nil, sampleConfig)
|
||
if len(goodHits) == 0 {
|
||
t.Error("справний зразок мусить дати рядки у формі")
|
||
}
|
||
if len(typoHits) != 0 {
|
||
t.Error("зразок з опискою мусить дати ПОРОЖНІЙ перелік — це і є видимий слід помилки")
|
||
}
|
||
if goodHits[0].LineNumber != 11 {
|
||
t.Errorf("номер рядка = %d, очікували 11: без номера знахідка не дія, а натяк",
|
||
goodHits[0].LineNumber)
|
||
}
|
||
}
|
||
|
||
// TestProbeMatchesAllLines — форма показує ВСІ збіги, а не перший.
|
||
//
|
||
// Прогін зупиняється на першому, і для висновку цього досить. Але
|
||
// людина у формі відповідає на інше питання: «мій зразок ловить те, що
|
||
// я думаю». Один рядок на нього не відповідає — обидва community з
|
||
// конфігу мають бути видимі.
|
||
func TestProbeMatchesAllLines(t *testing.T) {
|
||
r := ComplianceRule{
|
||
Kind: "regex_absent", Pattern: `snmp-server community (public|private)`,
|
||
Severity: "critical", ConfigType: "running",
|
||
}
|
||
re := regexp.MustCompile(r.Pattern)
|
||
|
||
hits, truncated := probeMatches(r, re, sampleConfig)
|
||
if truncated {
|
||
t.Error("двох рядків не буває забагато")
|
||
}
|
||
if len(hits) != 2 {
|
||
t.Fatalf("збігів %d, очікували 2 (public і private)", len(hits))
|
||
}
|
||
if hits[0].LineNumber != 7 || hits[1].LineNumber != 8 {
|
||
t.Errorf("номери рядків = %d, %d; очікували 7 і 8",
|
||
hits[0].LineNumber, hits[1].LineNumber)
|
||
}
|
||
|
||
// Стеля видима: обрізаний перелік, поданий як повний, — це та сама
|
||
// брехня, лише в інший бік.
|
||
big := strings.Repeat("snmp-server community public RO\n", complianceProbeMax+10)
|
||
hits, truncated = probeMatches(r, re, big)
|
||
if !truncated {
|
||
t.Error("перелік понад стелю мусить бути позначений як обрізаний")
|
||
}
|
||
if len(hits) != complianceProbeMax {
|
||
t.Errorf("віддано %d рядків, стеля %d", len(hits), complianceProbeMax)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 2. Валідація: що сервер не дасть зберегти
|
||
// ---------------------------------------------------------------------
|
||
|
||
func TestValidateComplianceRule(t *testing.T) {
|
||
base := ComplianceRule{
|
||
Name: "Telnet вимкнено", Kind: "regex_absent",
|
||
Pattern: `transport input.*telnet`, Severity: "critical",
|
||
ConfigType: "running", Enabled: true,
|
||
}
|
||
if err := validateComplianceRule(base); err != nil {
|
||
t.Fatalf("коректне правило відхилено: %v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
mut func(r *ComplianceRule)
|
||
// why — що саме зламалось би в бойовій мережі, якби це
|
||
// збереглось. Не оздоба: без цього рядка наступний читач не
|
||
// зрозуміє, чому саме тут потрібна відмова, а не умовчання.
|
||
why string
|
||
}{
|
||
{
|
||
name: "невідомий вид",
|
||
mut: func(r *ComplianceRule) { r.Kind = "jsonpath" },
|
||
why: "jsonpath є в ncm.rule_kind, але checkCompliance його не вміє — правило мовчало б",
|
||
},
|
||
{
|
||
name: "невідома серйозність",
|
||
mut: func(r *ComplianceRule) { r.Severity = "urgent" },
|
||
why: "приведення до ncm.rule_severity дало б 22P02 і 500 «внутрішня помилка»",
|
||
},
|
||
{
|
||
name: "описка в типі конфігу",
|
||
mut: func(r *ComplianceRule) { r.ConfigType = "runnning" },
|
||
why: "config_type — звичайний text: описка зберігається мовчки, жоден хост " +
|
||
"не підпадає, усі пари йдуть у пропущені, правило показує «порушень немає»",
|
||
},
|
||
{
|
||
name: "порожній зразок",
|
||
mut: func(r *ComplianceRule) { r.Pattern = " " },
|
||
why: "правило без зразка нічого не перевіряє",
|
||
},
|
||
{
|
||
name: "вираз не компілюється",
|
||
mut: func(r *ComplianceRule) { r.Pattern = `transport input (telnet` },
|
||
why: "інакше про друкарську помилку людина дізнається не з форми, а з тиші правила",
|
||
},
|
||
{
|
||
name: "перенос рядка у зразку «не має містити»",
|
||
mut: func(r *ComplianceRule) {
|
||
r.Kind = "must_not_contain"
|
||
r.Pattern = "line vty 0 4\n transport input telnet"
|
||
},
|
||
why: "перевірка йде порядково — зразок із переносом не збігся б НІКОЛИ, " +
|
||
"а для «не має містити» це вічне тихе «пройдено»",
|
||
},
|
||
{
|
||
name: "перенос рядка у зразку «має містити»",
|
||
mut: func(r *ComplianceRule) {
|
||
r.Kind = "must_contain"
|
||
r.Pattern = "ntp server 10.0.0.1\n"
|
||
},
|
||
why: "той самий зразок, що не збіжиться ніколи; тут він сипле порушеннями на " +
|
||
"кожному хості — помилка гучна, але однаково неправдива",
|
||
},
|
||
{
|
||
name: "перенос рядка у регулярному виразі",
|
||
mut: func(r *ComplianceRule) { r.Pattern = "transport input\n.*telnet" },
|
||
why: "вираз компілюється, але жоден поданий рядок переносу вже не містить",
|
||
},
|
||
{
|
||
name: "повернення каретки у зразку",
|
||
mut: func(r *ComplianceRule) { r.Pattern = "transport input telnet\r" },
|
||
why: "конфіги приходять і з CRLF: strings.Split по \\n лишає \\r у кінці рядка, " +
|
||
"тож зразок із \\r збігається то там, то там — залежно від пристрою",
|
||
},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
r := base
|
||
c.mut(&r)
|
||
err := validateComplianceRule(r)
|
||
if err == nil {
|
||
t.Fatalf("збереглося б, а не мало: %s", c.why)
|
||
}
|
||
if !errors.Is(err, ErrInvalid) {
|
||
t.Errorf("помилка мусить бути ErrInvalid (400, а не 500): %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestValidateAcceptsEveryConfigType — інший бік попередньої перевірки.
|
||
//
|
||
// Без нього «жоден тип не проходить» був би так само зеленим: перевірка
|
||
// відмов доводить лише те, що відмови бувають. Пара обовʼязкова — це
|
||
// рівно урок RLS: «чи не видно чужого» без «чи видно своє» не доводить
|
||
// нічого.
|
||
func TestValidateAcceptsEveryConfigType(t *testing.T) {
|
||
for _, ct := range []string{"running", "startup", "files"} {
|
||
r := ComplianceRule{
|
||
Name: "x", Kind: "must_contain", Pattern: "ntp",
|
||
Severity: "low", ConfigType: ct,
|
||
}
|
||
if err := validateComplianceRule(r); err != nil {
|
||
t.Errorf("тип конфігу %q мусить прийматись: %v", ct, err)
|
||
}
|
||
}
|
||
for _, sev := range complianceSeverityOrder {
|
||
r := ComplianceRule{
|
||
Name: "x", Kind: "must_contain", Pattern: "ntp",
|
||
Severity: sev, ConfigType: "running",
|
||
}
|
||
if err := validateComplianceRule(r); err != nil {
|
||
t.Errorf("серйозність %q мусить прийматись: %v", sev, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 3. Замок на вбудованих правилах
|
||
// ---------------------------------------------------------------------
|
||
|
||
func TestBuiltinImmutable(t *testing.T) {
|
||
cur := complianceRuleHead{
|
||
BuiltinKey: "snmp-default-community",
|
||
Name: "SNMP: типові community",
|
||
Kind: "regex_absent",
|
||
Pattern: `snmp-server community\s+(public|private)`,
|
||
ConfigType: "running",
|
||
}
|
||
same := ComplianceRule{
|
||
Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern, ConfigType: cur.ConfigType,
|
||
}
|
||
|
||
t.Run("те саме питання — правка проходить", func(t *testing.T) {
|
||
// Політика кабінету змінюється вільно: це і є те, заради чого
|
||
// 0071 завела копію правила на кабінет.
|
||
r := same
|
||
r.Enabled = false
|
||
r.Severity = "low"
|
||
r.Remediation = "прибрати community з конфігу"
|
||
r.Selector = Selector{Vendors: []string{"Cisco"}}
|
||
if err := checkBuiltinImmutable(cur, r); err != nil {
|
||
t.Errorf("вимкнути, звузити селектор і знизити серйозність мусить бути можна: %v", err)
|
||
}
|
||
})
|
||
|
||
locked := []struct {
|
||
field string
|
||
mut func(r *ComplianceRule)
|
||
}{
|
||
{"зразок", func(r *ComplianceRule) { r.Pattern = "community" }},
|
||
{"назва", func(r *ComplianceRule) { r.Name = "Наше правило" }},
|
||
{"вид умови", func(r *ComplianceRule) { r.Kind = "must_not_contain" }},
|
||
{"тип конфігу", func(r *ComplianceRule) { r.ConfigType = "files" }},
|
||
}
|
||
for _, c := range locked {
|
||
t.Run("замкнено: "+c.field, func(t *testing.T) {
|
||
r := same
|
||
c.mut(&r)
|
||
err := checkBuiltinImmutable(cur, r)
|
||
if err == nil {
|
||
t.Fatalf("%s вбудованого правила переписалось би, а значок «вбудоване» "+
|
||
"лишився б — і звіт посилався б на довідник, якого вже не дотримується",
|
||
c.field)
|
||
}
|
||
if !errors.Is(err, ErrInvalid) {
|
||
t.Errorf("мусить бути ErrInvalid: %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "копію") {
|
||
t.Errorf("відмова мусить називати вихід («зробіть копію»), а не лише заборону: %q",
|
||
err.Error())
|
||
}
|
||
})
|
||
}
|
||
|
||
t.Run("своє правило не замкнене", func(t *testing.T) {
|
||
own := complianceRuleHead{Name: "Наше", Kind: "must_contain",
|
||
Pattern: "ntp server", ConfigType: "running"}
|
||
r := ComplianceRule{Name: "Інше", Kind: "regex_absent",
|
||
Pattern: "telnet", ConfigType: "files"}
|
||
if err := checkBuiltinImmutable(own, r); err != nil {
|
||
t.Errorf("правило без builtin_key правиться цілком: %v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestComplianceSubjectChanged — що стирає картину прогону, а що ні.
|
||
//
|
||
// Це прямо про головну дію над вбудованим правилом — «вимкнути в
|
||
// себе». Якби вимкнення стирало результати, людина платила б за
|
||
// один клац усією картиною мережі по цьому правилу — і втрата
|
||
// була б тихою: на екрані просто стало б менше рядків.
|
||
func TestComplianceSubjectChanged(t *testing.T) {
|
||
cur := complianceRuleHead{
|
||
BuiltinKey: "telnet-disabled",
|
||
Name: "Telnet вимкнено",
|
||
Kind: "regex_absent",
|
||
Pattern: `transport input.*telnet`,
|
||
ConfigType: "running",
|
||
}
|
||
same := ComplianceRule{
|
||
Name: cur.Name, Kind: cur.Kind, Pattern: cur.Pattern,
|
||
ConfigType: cur.ConfigType, Severity: "high", Enabled: true,
|
||
}
|
||
|
||
keep := []struct {
|
||
name string
|
||
mut func(r *ComplianceRule)
|
||
}{
|
||
{"вимкнення", func(r *ComplianceRule) { r.Enabled = false }},
|
||
{"інша серйозність", func(r *ComplianceRule) { r.Severity = "low" }},
|
||
{"інша порада", func(r *ComplianceRule) { r.Remediation = "transport input ssh" }},
|
||
{"інший опис", func(r *ComplianceRule) { r.Description = "вимога безпеки" }},
|
||
}
|
||
for _, c := range keep {
|
||
t.Run("зберігає: "+c.name, func(t *testing.T) {
|
||
r := same
|
||
c.mut(&r)
|
||
if complianceSubjectChanged(cur, r) {
|
||
t.Error("питання не змінилось — результати мають лишитись")
|
||
}
|
||
})
|
||
}
|
||
|
||
drop := []struct {
|
||
name string
|
||
mut func(r *ComplianceRule)
|
||
}{
|
||
{"інший зразок", func(r *ComplianceRule) { r.Pattern = "telnet" }},
|
||
{"інший вид", func(r *ComplianceRule) { r.Kind = "must_not_contain" }},
|
||
{"інший тип конфігу", func(r *ComplianceRule) { r.ConfigType = "startup" }},
|
||
}
|
||
for _, c := range drop {
|
||
t.Run("стирає: "+c.name, func(t *testing.T) {
|
||
r := same
|
||
c.mut(&r)
|
||
if !complianceSubjectChanged(cur, r) {
|
||
t.Error("правило питає вже інше — старі результати більше ні про що не свідчать")
|
||
}
|
||
})
|
||
}
|
||
|
||
t.Run("звужений селектор стирає", func(t *testing.T) {
|
||
c := cur
|
||
c.Selector = Selector{} // було «до всіх»
|
||
r := same
|
||
r.Selector = Selector{GroupIDs: []string{"g-branch"}}
|
||
if !complianceSubjectChanged(c, r) {
|
||
t.Error("після звуження селектора результати лишились би про хости, " +
|
||
"яких правило вже не стосується")
|
||
}
|
||
})
|
||
|
||
t.Run("той самий селектор в іншій формі запису НЕ стирає", func(t *testing.T) {
|
||
// Головний випадок цього файла: `{}` з міграції 0071 і порожній
|
||
// Selector із форми — той самий склад хостів.
|
||
c := cur
|
||
var err error
|
||
if c.Selector, err = parseSelector(`{}`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r := same
|
||
r.Enabled = false // єдина справжня зміна: «вимкнути в себе»
|
||
if complianceSubjectChanged(c, r) {
|
||
t.Error("вимкнення вбудованого правила стерло б картину всієї мережі по ньому")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestSelectorComparedByValueNotByForm — найдорожча знахідка рецензії,
|
||
// відтворена на самих ПРЕДСТАВЛЕННЯХ.
|
||
//
|
||
// Тест навмисно не про прапорець «селектор змінився», а про два тексти,
|
||
// які насправді зустрічаються: `{}` кладе ncm.builtin_compliance_apply
|
||
// (0071), а сім ключів зі значенням null дає json.Marshal(Selector) —
|
||
// у store.Selector немає жодного `omitempty`.
|
||
//
|
||
// Стара перевірка жила в SQL (`selector IS DISTINCT FROM $3::jsonb`) і
|
||
// була ПРАВИЛЬНОЮ по суті: jsonb порівнює за змістом, а не за формою.
|
||
// Але для jsonb `{}` і `{"device_ids":null,…}` — різні значення. Отже,
|
||
// ПЕРШЕ збереження будь-якого вбудованого правила — вимкнути, знизити
|
||
// серйозність, дописати пораду — вважалось зміною селектора й стирало
|
||
// ncm.compliance_results. Помилка одноразова на правило, тому непомітна;
|
||
// а стерта картина мережі виглядає точнісінько як «у вас усе добре».
|
||
func TestSelectorComparedByValueNotByForm(t *testing.T) {
|
||
// Спершу доводимо, що проблема справді існує, а не вигадана: два
|
||
// описи ПОРОЖНЬОГО селектора — різні тексти й різні значення jsonb.
|
||
rawGo, err := json.Marshal(Selector{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
const stored = `{}`
|
||
if string(rawGo) == stored {
|
||
t.Fatalf("у Selector з'явився omitempty — цей тест треба переписати; marshal дав %s", rawGo)
|
||
}
|
||
if !jsonbDistinct(t, string(rawGo), stored) {
|
||
t.Fatal("jsonb вважав би ці два описи однаковими — тоді помилки не було б")
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
// stored — як селектор лежить у базі.
|
||
stored string
|
||
next Selector
|
||
same bool
|
||
}{
|
||
{"порожній із 0071 проти порожнього з форми", `{}`, Selector{}, true},
|
||
{"сім null-ключів проти порожнього з форми",
|
||
`{"device_ids":null,"group_ids":null,"site_ids":null,"kinds":null,` +
|
||
`"template_ids":null,"vendors":null,"tags":null}`, Selector{}, true},
|
||
{"порожній масив — це теж «до всіх»", `{"vendors":[]}`, Selector{}, true},
|
||
{"колонка NULL", ``, Selector{}, true},
|
||
{"вендори: той самий склад", `{"vendors":["Cisco"]}`,
|
||
Selector{Vendors: []string{"Cisco"}}, true},
|
||
{"вендори: інший пробіл у тексті", `{"vendors": [ "Cisco" ]}`,
|
||
Selector{Vendors: []string{"Cisco"}}, true},
|
||
{"вендори: 0071 із вендором проти форми з нулями в решті",
|
||
`{"vendors":["Cisco","Huawei"]}`,
|
||
Selector{Vendors: []string{"Cisco", "Huawei"}}, true},
|
||
|
||
// А ось це — справжні зміни складу хостів, і їх пропустити не
|
||
// можна: старі результати лишились би про хости, яких правило
|
||
// вже не стосується.
|
||
{"вендора додали", `{"vendors":["Cisco"]}`,
|
||
Selector{Vendors: []string{"Cisco", "Huawei"}}, false},
|
||
{"звузили до групи", `{}`,
|
||
Selector{GroupIDs: []string{"g-1"}}, false},
|
||
{"звузили до окремих хостів", `{}`,
|
||
Selector{DeviceIDs: []string{"d-1"}}, false},
|
||
{"мітки з'явились", `{}`,
|
||
Selector{Tags: map[string]string{"env": "prod"}}, false},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
cur, err := parseSelector(c.stored)
|
||
if err != nil {
|
||
t.Fatalf("розбір збереженого селектора: %v", err)
|
||
}
|
||
if got := selectorsEqual(cur, c.next); got != c.same {
|
||
t.Errorf("selectorsEqual = %v, очікували %v; порівнюватись має ЗНАЧЕННЯ, "+
|
||
"а не форма запису — інакше правка правила стирає картину мережі",
|
||
got, c.same)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// jsonbDistinct — те саме, що `a IS DISTINCT FROM b` для двох jsonb.
|
||
//
|
||
// Потрібне рівно для одного: показати в тесті, що стара перевірка в SQL
|
||
// не була «текстовим порівнянням, яке легко полагодити пробілом». Вона
|
||
// порівнювала значення — просто значення були різні, бо описи були
|
||
// різні.
|
||
func jsonbDistinct(t *testing.T, a, b string) bool {
|
||
t.Helper()
|
||
var ma, mb map[string]any
|
||
if err := json.Unmarshal([]byte(a), &ma); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := json.Unmarshal([]byte(b), &mb); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return !reflect.DeepEqual(ma, mb)
|
||
}
|
||
|
||
// TestSelectorJSONIsCanonical — те, що їде в базу, залежить лише від
|
||
// значення.
|
||
//
|
||
// ПРИЧИНА: selectorJSON — це не лише порівняння, а й те, що пишеться в
|
||
// колонку. Отже, база нормалізується сама, при кожному збереженні, і
|
||
// окрема міграція для цього не потрібна. Але тільки якщо форма стала.
|
||
func TestSelectorJSONIsCanonical(t *testing.T) {
|
||
empty, err := selectorJSON(Selector{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(empty) != "{}" {
|
||
t.Errorf("порожній селектор пишеться як %s, а 0071 кладе {} — знову дві форми того самого",
|
||
empty)
|
||
}
|
||
|
||
// Порожні виміри не потрапляють у запис узагалі: «жодної групи» і
|
||
// «поля немає» — одне твердження.
|
||
withEmpties, err := selectorJSON(Selector{
|
||
Vendors: []string{"Cisco"},
|
||
GroupIDs: []string{},
|
||
Tags: map[string]string{},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(withEmpties) != `{"vendors":["Cisco"]}` {
|
||
t.Errorf("канонічний запис = %s; порожні виміри мали зникнути", withEmpties)
|
||
}
|
||
}
|
||
|
||
// TestApplyComplianceCounts — лічильники списку правил рахуються з тих
|
||
// самих рядків, що й «Знахідки».
|
||
//
|
||
// ПРИЧИНА. Раніше їх давали два count(*) у запиті правил — по ВСІХ
|
||
// хостах кабінету, без Scope й без відсіву видалених. Інженер філії
|
||
// читав «5 з 12 не пройшли», відкривав знахідки — там дві. Крім
|
||
// розбіжності, це ще й витік: агрегат розповідав про хости, яких людині
|
||
// бачити не належить.
|
||
func TestApplyComplianceCounts(t *testing.T) {
|
||
rules := []ComplianceRule{
|
||
// Числа, «принесені з SQL по всьому кабінету»: саме їх треба
|
||
// перебити тим, що людина бачить.
|
||
{ID: "r-1", Name: "SNMP", Failed: 5, Passed: 7},
|
||
{ID: "r-2", Name: "Telnet", Failed: 3, Passed: 0},
|
||
}
|
||
visible := []ComplianceResult{
|
||
{RuleID: "r-1", DeviceID: "d-1", Passed: false},
|
||
{RuleID: "r-1", DeviceID: "d-2", Passed: false},
|
||
{RuleID: "r-1", DeviceID: "d-3", Passed: true},
|
||
}
|
||
|
||
got := ApplyComplianceCounts(rules, visible)
|
||
if got[0].Failed != 2 || got[0].Passed != 1 {
|
||
t.Errorf("SNMP: %d не пройшли з %d; очікували 2 і 1 — рівно те, що у «Знахідках»",
|
||
got[0].Failed, got[0].Failed+got[0].Passed)
|
||
}
|
||
// Правило, від якого людині не видно жодного хоста, мусить показати
|
||
// нулі, а не чуже число. «3 не пройшли» без жодного рядка нижче — це
|
||
// повідомлення про чужий парк.
|
||
if got[1].Failed != 0 || got[1].Passed != 0 {
|
||
t.Errorf("Telnet: %d/%d; очікували нулі — видимих результатів немає",
|
||
got[1].Failed, got[1].Passed)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 4. Звіт
|
||
// ---------------------------------------------------------------------
|
||
|
||
func reportFixture() ([]ComplianceRule, []ComplianceResult) {
|
||
rules := []ComplianceRule{
|
||
{
|
||
ID: "r-snmp", Name: "SNMP: типові community", Kind: "regex_absent",
|
||
Pattern: `community\s+(public|private)`, Severity: "critical",
|
||
ConfigType: "running", Enabled: true, BuiltinKey: "snmp-default-community",
|
||
Remediation: "no snmp-server community public",
|
||
},
|
||
{
|
||
ID: "r-telnet", Name: "Telnet вимкнено", Kind: "regex_absent",
|
||
Pattern: `transport input.*telnet`, Severity: "high",
|
||
ConfigType: "running", Enabled: true, BuiltinKey: "telnet-disabled",
|
||
Remediation: "line vty 0 4 / transport input ssh",
|
||
},
|
||
{
|
||
// Вимкнене правило: у прогоні його немає, результатів немає,
|
||
// і звіт мусить сказати про це вголос.
|
||
ID: "r-acl", Name: "Керування обмежене списком", Kind: "must_contain",
|
||
Pattern: "access-class", Severity: "high",
|
||
ConfigType: "running", Enabled: false, BuiltinKey: "mgmt-acl",
|
||
},
|
||
{
|
||
// Своє правило, увімкнене, але жодного разу не проганялось.
|
||
ID: "r-own", Name: "Наш NTP", Kind: "must_contain",
|
||
Pattern: "ntp server 10.0.0.1", Severity: "medium",
|
||
ConfigType: "running", Enabled: true,
|
||
},
|
||
}
|
||
at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC)
|
||
results := []ComplianceResult{
|
||
{RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical",
|
||
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
|
||
Line: "snmp-server community public RO", LineNumber: 7, CheckedAt: at},
|
||
{RuleID: "r-snmp", RuleName: rules[0].Name, Severity: "critical",
|
||
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false,
|
||
Line: "snmp-server community private RW", LineNumber: 8, CheckedAt: at},
|
||
{RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high",
|
||
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
|
||
Line: "transport input telnet ssh", LineNumber: 12,
|
||
CheckedAt: at.Add(time.Minute)},
|
||
{RuleID: "r-telnet", RuleName: rules[1].Name, Severity: "high",
|
||
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: true, CheckedAt: at},
|
||
}
|
||
return rules, results
|
||
}
|
||
|
||
func TestComplianceReportOf(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
|
||
rep := ComplianceReportOf(at, "ops", false, rules, results)
|
||
|
||
if rep.RulesTotal != 4 || rep.RulesEnabled != 3 || rep.RulesBuiltin != 3 {
|
||
t.Errorf("склад правил: усього %d, увімкнено %d, вбудованих %d; очікували 4/3/3",
|
||
rep.RulesTotal, rep.RulesEnabled, rep.RulesBuiltin)
|
||
}
|
||
// Два правила без жодної перевірки: вимкнене й те, що ще не
|
||
// проганялось. Без цього числа нулі в звіті означали б «усе гаразд».
|
||
if rep.RulesNeverRun != 2 {
|
||
t.Errorf("правил без перевірки = %d, очікували 2", rep.RulesNeverRun)
|
||
}
|
||
if rep.Checks != 4 || rep.Failed != 3 {
|
||
t.Errorf("перевірок %d, порушень %d; очікували 4 і 3", rep.Checks, rep.Failed)
|
||
}
|
||
if rep.Devices != 2 || rep.DevicesFailed != 2 {
|
||
t.Errorf("хостів %d, з них із порушеннями %d; очікували 2 і 2",
|
||
rep.Devices, rep.DevicesFailed)
|
||
}
|
||
if rep.LastCheckAt == nil || !rep.LastCheckAt.Equal(
|
||
time.Date(2026, 8, 28, 9, 31, 0, 0, time.UTC)) {
|
||
t.Errorf("остання перевірка = %v, очікували найпізнішу з результатів", rep.LastCheckAt)
|
||
}
|
||
|
||
// Розподіл за серйозністю — з нулями. «Критичних 0» — це відповідь;
|
||
// відсутній рядок читається як «не перевіряли».
|
||
want := map[string]int{"critical": 2, "high": 1, "medium": 0, "low": 0, "info": 0}
|
||
if len(rep.BySeverity) != len(complianceSeverityOrder) {
|
||
t.Fatalf("рядків серйозності %d, очікували %d",
|
||
len(rep.BySeverity), len(complianceSeverityOrder))
|
||
}
|
||
for i, s := range rep.BySeverity {
|
||
if s.Severity != complianceSeverityOrder[i] {
|
||
t.Errorf("порядок серйозностей збився на %d: %q", i, s.Severity)
|
||
}
|
||
if s.Count != want[s.Severity] {
|
||
t.Errorf("%s: %d, очікували %d", s.Severity, s.Count, want[s.Severity])
|
||
}
|
||
}
|
||
|
||
// Порада їде разом зі знахідкою: звіт читає той, хто цього конфігу
|
||
// ніколи не бачив.
|
||
if len(rep.Violations) != 3 {
|
||
t.Fatalf("порушень у переліку %d, очікували 3", len(rep.Violations))
|
||
}
|
||
if rep.Violations[0].Severity != "critical" {
|
||
t.Errorf("перше порушення мусить бути найважче, а не %q", rep.Violations[0].Severity)
|
||
}
|
||
for _, v := range rep.Violations {
|
||
if v.Remediation == "" {
|
||
t.Errorf("порушення %q/%q без поради — читач не знатиме, що робити",
|
||
v.DeviceName, v.RuleName)
|
||
}
|
||
}
|
||
|
||
// Стан правил у шапці: увімкнене без жодної перевірки мусить бути
|
||
// відрізнюване від увімкненого й перевіреного.
|
||
byName := map[string]ComplianceReportRule{}
|
||
for _, r := range rep.Rules {
|
||
byName[r.Name] = r
|
||
}
|
||
if r := byName["Наш NTP"]; r.Checked != 0 || !r.Enabled {
|
||
t.Errorf("правило «Наш NTP» мало бути ввімкненим і без перевірок: %+v", r)
|
||
}
|
||
if r := byName["SNMP: типові community"]; r.Checked != 2 || r.Failed != 2 {
|
||
t.Errorf("SNMP: перевірок %d, порушень %d; очікували 2 і 2", r.Checked, r.Failed)
|
||
}
|
||
if r := byName["Керування обмежене списком"]; r.Enabled {
|
||
t.Error("вимкнене правило мусить лишатись у складі звіту саме як вимкнене")
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportSkipsDisabledRuleResults — у підсумку немає
|
||
// відповідей на питання, яких більше не ставлять.
|
||
//
|
||
// ПРИЧИНА. RunCompliance вимкнене правило пропускає й старих його
|
||
// результатів не чіпає — і правильно робить: «вимкнути в себе» головна
|
||
// дія над вбудованим правилом, і платити за неї стертою картиною мережі
|
||
// немає за що. Але доки ці рядки рахувались нарівні з рештою, вимкнене
|
||
// правило давало порушення НАЗАВЖДИ: клієнт свідомо знімає вимогу, а
|
||
// звіт для аудитора й далі показує по ній чотири порушення з датою
|
||
// піврічної давнини.
|
||
//
|
||
// ЩО МУСИТЬ ЛИШИТИСЬ ВИДИМИМ: саме правило. Вимкнене правило зникнути зі
|
||
// звіту не може — звіт без нього читається як «вимоги немає», і це та
|
||
// сама помилка, від якої застерігає шапка ncm_compliance_report.go.
|
||
func TestComplianceReportSkipsDisabledRuleResults(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
at := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC)
|
||
|
||
// r-acl вимкнене; додаємо йому старі знахідки — рівно те, що лежить
|
||
// у базі в клієнта, який колись прогнав перевірку, а тоді вимогу зняв.
|
||
stale := []ComplianceResult{
|
||
{RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high",
|
||
DeviceID: "d-1", DeviceName: "core-sw-01", Passed: false,
|
||
Line: "", LineNumber: 0, CheckedAt: at.Add(-90 * 24 * time.Hour)},
|
||
{RuleID: "r-acl", RuleName: "Керування обмежене списком", Severity: "high",
|
||
DeviceID: "d-2", DeviceName: "acc-sw-02", Passed: false,
|
||
CheckedAt: at.Add(-90 * 24 * time.Hour)},
|
||
}
|
||
|
||
base := ComplianceReportOf(at, "ops", false, rules, results)
|
||
rep := ComplianceReportOf(at, "ops", false, rules, append(append([]ComplianceResult{}, results...), stale...))
|
||
|
||
if rep.Failed != base.Failed {
|
||
t.Errorf("порушень %d проти %d без старих рядків — у підсумок потрапили "+
|
||
"відповіді на зняте питання", rep.Failed, base.Failed)
|
||
}
|
||
if len(rep.Violations) != len(base.Violations) {
|
||
t.Errorf("порушень у переліку %d, очікували %d", len(rep.Violations), len(base.Violations))
|
||
}
|
||
for _, v := range rep.Violations {
|
||
if v.RuleName == "Керування обмежене списком" {
|
||
t.Error("знахідка вимкненого правила потрапила в перелік порушень")
|
||
}
|
||
}
|
||
for _, s := range rep.BySeverity {
|
||
if s.Severity == "high" && s.Count != 1 {
|
||
t.Errorf("високих порушень %d, очікували 1 — старі рядки додались у розподіл", s.Count)
|
||
}
|
||
}
|
||
|
||
// А правило зі звіту нікуди не поділось — воно там саме як вимкнене.
|
||
var seen bool
|
||
for _, r := range rep.Rules {
|
||
if r.Name == "Керування обмежене списком" {
|
||
seen = true
|
||
if r.Enabled {
|
||
t.Error("правило мало лишитись вимкненим")
|
||
}
|
||
if r.Failed != 0 || r.Checked != 0 {
|
||
t.Errorf("вимкнене правило показує %d/%d — це числа з питання, якого не ставлять",
|
||
r.Failed, r.Checked)
|
||
}
|
||
}
|
||
}
|
||
if !seen {
|
||
t.Error("вимкнене правило зникло зі складу — звіт без нього читається як «вимоги немає»")
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportPartialScope — звіт, зроблений людиною, яка
|
||
// бачить не весь парк.
|
||
//
|
||
// Найдорожча помилка звіту: інженер філії вивантажує правду про свої
|
||
// хости, а виглядає це як правда про мережу. Підсумок мусить рахуватись
|
||
// ПІСЛЯ відсіву невидимого, інакше шапка суперечитиме переліку.
|
||
func TestComplianceReportPartialScope(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
visible := results[:0:0]
|
||
for _, x := range results {
|
||
if x.DeviceID == "d-1" {
|
||
visible = append(visible, x)
|
||
}
|
||
}
|
||
rep := ComplianceReportOf(time.Now(), "branch-eng", true, rules, visible)
|
||
|
||
if !rep.PartialScope {
|
||
t.Error("обмежена видимість мусить лишити слід у звіті")
|
||
}
|
||
if rep.Devices != 1 {
|
||
t.Errorf("хостів %d, очікували 1: рахувати треба видиме", rep.Devices)
|
||
}
|
||
if rep.Failed != len(rep.Violations) {
|
||
t.Errorf("шапка каже %d порушень, у переліку %d — звіт суперечить сам собі",
|
||
rep.Failed, len(rep.Violations))
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportNeverRun — звіт, коли перевірки не було жодної.
|
||
//
|
||
// Найнебезпечніший стан із усіх: нулі скрізь, порожній перелік порушень.
|
||
// Читається як бездоганна мережа.
|
||
func TestComplianceReportNeverRun(t *testing.T) {
|
||
rules, _ := reportFixture()
|
||
rep := ComplianceReportOf(time.Now(), "ops", false, rules, nil)
|
||
|
||
if rep.LastCheckAt != nil {
|
||
t.Error("останньої перевірки не було — поле мусить лишитись порожнім")
|
||
}
|
||
if rep.RulesNeverRun != len(rules) {
|
||
t.Errorf("без перевірки %d із %d правил", rep.RulesNeverRun, len(rules))
|
||
}
|
||
|
||
var buf strings.Builder
|
||
if err := ComplianceReportCSV(&buf, rep); err != nil {
|
||
t.Fatalf("CSV: %v", err)
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, "не проводилась") {
|
||
t.Error("шапка мусить сказати, що перевірки не було")
|
||
}
|
||
if !strings.Contains(out, "перевірок не було") {
|
||
t.Error("порожній розділ порушень мусить пояснити себе, а не мовчати зеленим")
|
||
}
|
||
}
|
||
|
||
func TestComplianceReportCSV(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
|
||
rep := ComplianceReportOf(at, "ops", false, rules, results)
|
||
|
||
var buf strings.Builder
|
||
if err := ComplianceReportCSV(&buf, rep); err != nil {
|
||
t.Fatalf("CSV: %v", err)
|
||
}
|
||
out := buf.String()
|
||
|
||
if !strings.HasPrefix(out, csvBOM) {
|
||
t.Error("без BOM український Excel читає UTF-8 як cp1251 — назви хостів стануть кракозябрами")
|
||
}
|
||
if !strings.Contains(out, "sep=;") {
|
||
t.Error("без sep=; Excel бере роздільником кому")
|
||
}
|
||
if !strings.Contains(out, "\r\n") {
|
||
t.Error("Excel чекає CRLF")
|
||
}
|
||
|
||
// Шапка — те, що робить файл документом. Без дати й складу правил
|
||
// два роздруки з різними числами нічим пояснити.
|
||
for _, must := range []string{
|
||
"Сформовано;2026-08-28T10:00:00Z",
|
||
"Правил усього;4",
|
||
"Порушень;3",
|
||
"Порушень, критична;2",
|
||
"СКЛАД ПРАВИЛ НА МОМЕНТ ЗВІТУ",
|
||
"ПОРУШЕННЯ",
|
||
} {
|
||
if !strings.Contains(out, must) {
|
||
t.Errorf("у звіті немає рядка %q", must)
|
||
}
|
||
}
|
||
|
||
// Зразок правила — у складі. Це найважливіше поле шапки: за місяць
|
||
// його змінять, і без відбитка в звіті пояснити різницю чисел буде
|
||
// нічим.
|
||
if !strings.Contains(out, `community\s+(public|private)`) {
|
||
t.Error("склад правил мусить нести самі зразки, а не лише назви")
|
||
}
|
||
// Походження — теж: аудитор має відрізняти правило постачальника від
|
||
// написаного клієнтом.
|
||
if !strings.Contains(out, "вбудоване (snmp-default-community)") {
|
||
t.Error("склад правил мусить називати походження")
|
||
}
|
||
if !strings.Contains(out, "увімкнено, але жодної перевірки") {
|
||
t.Error("правило без жодної перевірки мусить бути видиме окремим станом")
|
||
}
|
||
if !strings.Contains(out, "вимкнено") {
|
||
t.Error("вимкнене правило мусить бути видиме як вимкнене")
|
||
}
|
||
if !strings.Contains(out, "line vty 0 4 / transport input ssh") {
|
||
t.Error("порада мусить бути поруч зі знахідкою")
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportCSVFormulaInjection — вивантаження не виконується
|
||
// в Excel.
|
||
//
|
||
// ЦІНА ПОМИЛКИ. У клітинку «Що знайдено» їде СИРИЙ рядок конфігу
|
||
// пристрою: текст, який у мережу поклав хтось із доступом до заліза —
|
||
// тобто, за задумом самої перевірки відповідності, потенційно не той,
|
||
// кому довіряють. Файл відкриває керівник або аудитор, у Excel, зі
|
||
// своїми правами. Клітинка `=cmd|'/C calc'!A0` виконується при
|
||
// відкритті. Звіт про безпеку конфігів, який виконує код на машині
|
||
// аудитора, — це рівно протилежне до того, чим він є.
|
||
//
|
||
// Перевіряються всі поля, що приходять ззовні, а не одне: захист,
|
||
// покладений на одну клітинку, забувають у сусідній.
|
||
func TestComplianceReportCSVFormulaInjection(t *testing.T) {
|
||
rules := []ComplianceRule{{
|
||
ID: "r-1", Name: "=HYPERLINK(\"http://evil\",\"звіт\")", Kind: "must_not_contain",
|
||
Pattern: "+telnet", Severity: "critical", ConfigType: "running", Enabled: true,
|
||
Remediation: "@SUM(A1:A9)",
|
||
}}
|
||
results := []ComplianceResult{{
|
||
RuleID: "r-1", RuleName: rules[0].Name, Severity: "critical",
|
||
DeviceID: "d-1", DeviceName: "-2+3+cmd|'/C calc'!A0", Passed: false,
|
||
Line: `=cmd|'/C calc'!A0`, LineNumber: 11,
|
||
CheckedAt: time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC),
|
||
}}
|
||
|
||
var buf strings.Builder
|
||
if err := ComplianceReportCSV(&buf, ComplianceReportOf(
|
||
time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC), "ops", false, rules, results)); err != nil {
|
||
t.Fatalf("CSV: %v", err)
|
||
}
|
||
out := buf.String()
|
||
|
||
// Небезпечні клітинки мусять бути екрановані апострофом — і не
|
||
// втратити при цьому вмісту: рядок конфігу лишається доказом, він
|
||
// лише перестає бути формулою.
|
||
for _, want := range []string{
|
||
`'=cmd|'`, // рядок конфігу з пристрою
|
||
`'-2+3+cmd|'`, // імʼя хоста
|
||
`'=HYPERLINK(`, // назва правила
|
||
`'+telnet`, // зразок правила
|
||
`'@SUM(A1:A9)`, // порада «як виправити»
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("у файлі немає %q — клітинка поїхала в Excel формулою", want)
|
||
}
|
||
}
|
||
|
||
// Зворотний бік: жодна клітинка не сміє ПОЧИНАТИСЬ із небезпечного
|
||
// символу. Перелік вище доводить, що ми щось екранували; ця
|
||
// перевірка — що не лишилось нічого неекранованого.
|
||
for i, line := range strings.Split(out, "\r\n") {
|
||
for _, cell := range strings.Split(strings.TrimPrefix(line, csvBOM), ";") {
|
||
cell = strings.TrimPrefix(cell, `"`)
|
||
if cell == "" || !csvFormulaLead(cell[0]) {
|
||
continue
|
||
}
|
||
if _, err := strconv.ParseFloat(cell, 64); err == nil {
|
||
continue // число, а не формула
|
||
}
|
||
if strings.HasPrefix(line, "sep=") {
|
||
continue
|
||
}
|
||
t.Errorf("рядок %d: клітинка %q починається з символу формули", i+1, cell)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCSVSafeCellKeepsNumbers — екранування не ламає чисел.
|
||
//
|
||
// ПРИЧИНА окремої перевірки: `-5` починається з небезпечного символу й
|
||
// формулою не є. Екранувати його означало б перетворити число на текст —
|
||
// і стовпчик простою в SLA-звіті перестав би складатися. Читач звіту
|
||
// побачив би не помилку, а порожній підсумок.
|
||
func TestCSVSafeCellKeepsNumbers(t *testing.T) {
|
||
safe := []string{"-5", "-0.75", "+12", "99.912", "0", "core-sw-01", ""}
|
||
for _, s := range safe {
|
||
if got := csvSafeCell(s); got != s {
|
||
t.Errorf("csvSafeCell(%q) = %q — значення зіпсовано без потреби", s, got)
|
||
}
|
||
}
|
||
danger := []string{"=1+1", "+cmd|'/C calc'!A0", "-2+3+cmd", "@SUM(A1)", "\t=1+1", "\r=1+1"}
|
||
for _, s := range danger {
|
||
got := csvSafeCell(s)
|
||
if !strings.HasPrefix(got, "'") {
|
||
t.Errorf("csvSafeCell(%q) = %q — формула поїхала б у таблицю як формула", s, got)
|
||
}
|
||
if strings.TrimPrefix(got, "'") != s {
|
||
t.Errorf("csvSafeCell(%q) = %q — вміст клітинки втрачено", s, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportCSVEndMarker — обрізаний файл видно як обрізаний.
|
||
//
|
||
// ПРИЧИНА. Звіт іде в потік ПІСЛЯ статусу 200: помилку запису посеред
|
||
// стріму обробник лише журналює, а в людини на диску лишається коротший
|
||
// файл. Обрізаний CSV відкривається, читається й виглядає повним — у
|
||
// ньому просто менше порушень. Кінцевий маркер робить різницю видимою
|
||
// тому, хто відкрив файл, а не тому, хто читає журнал сервера.
|
||
func TestComplianceReportCSVEndMarker(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
rep := ComplianceReportOf(time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC),
|
||
"ops", false, rules, results)
|
||
|
||
var buf strings.Builder
|
||
if err := ComplianceReportCSV(&buf, rep); err != nil {
|
||
t.Fatalf("CSV: %v", err)
|
||
}
|
||
out := buf.String()
|
||
|
||
if !strings.Contains(out, "КІНЕЦЬ ЗВІТУ") {
|
||
t.Fatal("без кінцевого маркера обрізаний файл не відрізнити від повного")
|
||
}
|
||
// Маркер мусить бути саме в КІНЦІ, інакше він нічого не доводить.
|
||
if !strings.HasPrefix(strings.TrimSpace(out[strings.LastIndex(out, "\r\n\r\n")+4:]), "КІНЕЦЬ ЗВІТУ") {
|
||
t.Error("маркер стоїть не в кінці файла")
|
||
}
|
||
// І мусить нести число, з яким можна звірити перелік вище.
|
||
if !strings.Contains(out, "порушень у файлі: "+strconv.Itoa(len(rep.Violations))) {
|
||
t.Error("маркер без числа не дає з чим звірити перелік")
|
||
}
|
||
}
|
||
|
||
// TestComplianceReportStableOrder — два вивантаження того самого стану
|
||
// дають той самий файл.
|
||
//
|
||
// Не косметика: звіти порівнюють diff-ом («що змінилось за місяць»), і
|
||
// перетасований порядок рядків робить це порівняння нечитабельним —
|
||
// тобто скасовує єдиний спосіб побачити рух.
|
||
func TestComplianceReportStableOrder(t *testing.T) {
|
||
rules, results := reportFixture()
|
||
at := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
|
||
|
||
shuffled := []ComplianceResult{results[3], results[1], results[2], results[0]}
|
||
|
||
var a, b strings.Builder
|
||
if err := ComplianceReportCSV(&a, ComplianceReportOf(at, "ops", false, rules, results)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ComplianceReportCSV(&b, ComplianceReportOf(at, "ops", false, rules, shuffled)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a.String() != b.String() {
|
||
t.Error("порядок результатів у вибірці не має впливати на файл звіту")
|
||
}
|
||
}
|
||
|
||
func TestComplianceReportFileNameHasDate(t *testing.T) {
|
||
rep := ComplianceReport{GeneratedAt: time.Date(2026, 8, 28, 10, 5, 0, 0, time.UTC)}
|
||
got := ComplianceReportFileName(rep)
|
||
if got != "compliance-2026-08-28-1005.csv" {
|
||
t.Errorf("імʼя файла = %q; без дати десяток таких файлів у папці не розрізнити", got)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 5. Чого тут НЕМАЄ
|
||
// ---------------------------------------------------------------------
|
||
|
||
// TestNothingHereProvesTheWholeChain нічого не перевіряє. Це список меж,
|
||
// поставлений там, де його прочитають, — у файлі з тестами, а не в
|
||
// документі, який відкривають раз.
|
||
//
|
||
// НЕ ПОКРИТО ТУТ:
|
||
//
|
||
// 1. SaveComplianceRule / DeleteComplianceRule проти справжньої бази.
|
||
// Замок вбудованих правил перевірено на checkBuiltinImmutable —
|
||
// чистій функції. Що SaveComplianceRule справді читає поточний рядок
|
||
// ПІД БЛОКУВАННЯМ у тій самій транзакції й справді кличе цю функцію
|
||
// перед UPDATE, не доводить ніщо: ці рядки перевіряються лише
|
||
// прогоном проти Postgres (NETPULSE_TEST_DSN), який на машині
|
||
// розробника мовчки пропускається.
|
||
//
|
||
// 2. RLS. Що правило одного кабінету не видно з іншого й що звіт не
|
||
// збирає чужих хостів — тут не перевіряється взагалі. Ізоляція
|
||
// живе в політиках бази й у InTenantTx.
|
||
//
|
||
// 3. Відсів за Scope. Часткова видимість подана у ComplianceReportOf
|
||
// готовим прапорцем; що httpapi справді відсіює невидимі хости
|
||
// ПЕРЕД підрахунком (а не після), перевіряється лише читанням коду.
|
||
// Помилка тут дала б рівно ту суперечність, від якої застерігає
|
||
// TestComplianceReportPartialScope, — і цей тест її б не спіймав.
|
||
//
|
||
// 4. Розшифрування конфігу. ProbeComplianceRule ходить у ConfigBody із
|
||
// keyring; сервер без ключа відповідає 503. Перевірено вручну,
|
||
// тестом — ні.
|
||
//
|
||
// 5. Стирання результатів при зміні СЕЛЕКТОРА — лише ЧАСТКОВО.
|
||
// Порівняння самих представлень тепер перевірене без бази
|
||
// (TestSelectorComparedByValueNotByForm: `{}` із 0071 проти семи
|
||
// null-ключів із json.Marshal). Це та ланка, де жила помилка, і
|
||
// раніше її з тесту було вийнято готовим прапорцем.
|
||
// Але НЕ перевірено, що SaveComplianceRule справді читає збережений
|
||
// селектор тим самим SELECT ... FOR UPDATE і справді кличе
|
||
// complianceSubjectChanged перед DELETE, — це видно лише прогоном
|
||
// проти Postgres.
|
||
//
|
||
// 6. Прибирання результатів по хостах, які випали із селектора
|
||
// (pruneComplianceResults), і фільтри `cr.enabled` /
|
||
// `d.deleted_at IS NULL` у ListComplianceResults. Це SQL, і без
|
||
// NETPULSE_TEST_DSN його не перевіряє ніщо. Пораховано в Go тільки
|
||
// другий заслін — що ComplianceReportOf не рахує результатів
|
||
// вимкненого правила (TestComplianceReportSkipsDisabledRuleResults).
|
||
// Для видаленого хоста другого заслону немає взагалі: результат
|
||
// несе device_id, а не ознаку «хост живий», і звіт про це не знає.
|
||
//
|
||
// 7. Відсів за Scope В ЗАПИТІ вендорів (ListComplianceVendors) —
|
||
// той самий випадок: предикат у SQL, тестів без бази немає.
|
||
// ApplyComplianceCounts перевірено як чиста функція, але що обробник
|
||
// годує її саме видимими результатами, доводить лише читання коду.
|
||
//
|
||
// 8. CSV у справжньому Excel. Перевіряється наявність BOM, `sep=;`,
|
||
// CRLF і апострофа перед формулою — тобто те, що МАЄ змусити Excel
|
||
// прочитати файл правильно й не виконати клітинку. Що він справді
|
||
// так робить — і що те саме роблять LibreOffice, Numbers і Google
|
||
// Sheets, у яких свої переліки небезпечних символів, — може довести
|
||
// лише сам табличний процесор.
|
||
//
|
||
// 9. Форма. Перевірки сторінки живуть у web/src/test/compliance.test.tsx і
|
||
// ходять до ПІДСТАВНОГО сервера. Що справжні обробники віддають
|
||
// саме ту форму, яку там підставлено, не доводить ні той файл, ні цей.
|
||
//
|
||
// 10. Головне: жоден тест не доводить, що набір правил ПОВНИЙ. Двадцять
|
||
// правил, які всі до одного зелені, і повна відсутність правил про
|
||
// цілий клас ризиків дають на екрані однакову картинку. Вигляд
|
||
// ncm.builtin_compliance_coverage із 0071 відповідає на це для
|
||
// вбудованих правил і не відповідає для написаних руками.
|
||
func TestNothingHereProvesTheWholeChain(t *testing.T) {
|
||
t.Skip("перелік меж покриття; сам нічого не перевіряє — читайте коментар вище")
|
||
}
|