V 1.5.0
This commit is contained in:
parent
1f2d554d6a
commit
8707068ecd
4 changed files with 572 additions and 3 deletions
|
|
@ -49,6 +49,7 @@ auspex [флаг] [аргументы]
|
||||||
├── -fd → runFDCheck()
|
├── -fd → runFDCheck()
|
||||||
├── -dt → runDockerTUI()
|
├── -dt → runDockerTUI()
|
||||||
├── -fw → runFirewallTUI()
|
├── -fw → runFirewallTUI()
|
||||||
|
├── -au → runAudit(category)
|
||||||
├── -v → версия (+ вариант сборки: full/generic)
|
├── -v → версия (+ вариант сборки: full/generic)
|
||||||
└── -h → usage
|
└── -h → usage
|
||||||
```
|
```
|
||||||
|
|
@ -389,6 +390,32 @@ Truncate — единственное действие, которое осво
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `audit.go` — комплексный аудит-скан (read-only)
|
||||||
|
|
||||||
|
**Точка входа:** `runAudit(category string)`
|
||||||
|
|
||||||
|
Не проверяет "что-то одно", а прогоняет 7 категорий и печатает единый отчёт, отсортированный по severity (critical → warning → info). Полностью read-only — ничего не чинит и не меняет, только собирает находки. Задуман как первый шаг при поиске бага: что на тренировочном стенде (не связанном с WAF), что на реальном сервере.
|
||||||
|
|
||||||
|
**Категории:** `systemd`, `nginx` (детектит и nginx, и angie), `docker`, `cert`, `disk`, `network`, `firewall` — можно запустить одну через `auspex -au <категория>` (есть алиасы: `svc`→systemd, `angie`/`proxy`→nginx, `tls`/`ssl`→cert, `logs`→disk, `dns`→network, `iptables`/`ufw`→firewall).
|
||||||
|
|
||||||
|
**Переиспользует существующие модули напрямую** (не дублирует парсинг):
|
||||||
|
- `ccFindCerts`/`ccParseCert` (certcheck.go) — сроки сертификатов
|
||||||
|
- `dcCheckDockerAvailable`/`dcGetContainers` (dockercheck.go) — статус контейнеров
|
||||||
|
- `fdScan`/`fdHumanSize` (fdcheck.go) — удалённые-но-открытые файлы как признак утечки места
|
||||||
|
- `fwDetectBackend`/`ListRules` (firewall.go) — список firewall-правил
|
||||||
|
|
||||||
|
**Кросс-проверки между категориями — самая ценная часть:**
|
||||||
|
| Проверка | Как | Что выявляет |
|
||||||
|
|---|---|---|
|
||||||
|
| nginx `listen` ↔ реальные сокеты | парсит `nginx/angie -T`, сверяет с `ss -tlnp`/`-ulnp` (`auListeningPorts`) | конфиг применён, но процесс не слушает порт (не перечитал конфиг / упал) |
|
||||||
|
| nginx upstream ↔ доступность | парсит `server host:port;` из `-T`, TCP dial с таймаутом 2 сек | бэкенд не отвечает или неверный адрес в upstream |
|
||||||
|
| сертификат на диске ↔ то, что реально отдаёт TLS | `ccParseCert` против `tls.Dial` на `:443` | сервис не перечитал сертификат после обновления (самый частый "необъяснимый" баг с TLS) |
|
||||||
|
| firewall-правила ↔ слушающие порты | пересечение `ListRules()` и `auListeningPorts()` в обе стороны | правило разрешает порт, где никто не слушает (устарело) — и наоборот, порт слушает без явного allow (может блокироваться default policy) |
|
||||||
|
|
||||||
|
Кросс-проверки по firewall и network — эвристические (не учитывают, например, ACCEPT default policy), поэтому такие находки идут с severity `warning`, а не `critical`, и в конце отчёта есть явная оговорка об этом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `webcheck.go` — проверка доступности сайтов
|
### `webcheck.go` — проверка доступности сайтов
|
||||||
|
|
||||||
**Точка входа:** `runWebCheck(sidArg string)`
|
**Точка входа:** `runWebCheck(sidArg string)`
|
||||||
|
|
@ -573,6 +600,7 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать
|
||||||
| dockerview | `dv` |
|
| dockerview | `dv` |
|
||||||
| fdcheck | `fd` |
|
| fdcheck | `fd` |
|
||||||
| firewall | `fw` |
|
| firewall | `fw` |
|
||||||
|
| audit | `au` |
|
||||||
| email | `email` |
|
| email | `email` |
|
||||||
| mattermost | `mm` |
|
| mattermost | `mm` |
|
||||||
| webcheck | `wc` |
|
| webcheck | `wc` |
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
| `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) |
|
| `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) |
|
||||||
| `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove |
|
| `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove |
|
||||||
| `-fw` | Firewall — TUI для ufw/nftables/iptables: просмотр правил + формы (allow/deny, forward, rate-limit) с dry-run и автооткатом |
|
| `-fw` | Firewall — TUI для ufw/nftables/iptables: просмотр правил + формы (allow/deny, forward, rate-limit) с dry-run и автооткатом |
|
||||||
|
| `-au` | Audit — комплексная read-only диагностика (systemd/nginx/docker/cert/disk/network/firewall) с кросс-проверками между категориями; `auspex -au <категория>` для одной секции |
|
||||||
|
|
||||||
Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны
|
Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны
|
||||||
только в сборке `full` (см. [Сборка](#сборка)).
|
только в сборке `full` (см. [Сборка](#сборка)).
|
||||||
|
|
@ -36,6 +37,7 @@ auspex/
|
||||||
├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker)
|
├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker)
|
||||||
├── firewall.go # -fw: бэкенды ufw/nftables/iptables (detect/list/apply/undo)
|
├── firewall.go # -fw: бэкенды ufw/nftables/iptables (detect/list/apply/undo)
|
||||||
├── firewall_tui.go # -fw: TUI-слой (формы, review, rollback-таймер)
|
├── firewall_tui.go # -fw: TUI-слой (формы, review, rollback-таймер)
|
||||||
|
├── audit.go # -au: комплексный аудит-скан (read-only)
|
||||||
├── portsview.go # -ss: TUI просмотр занятых портов
|
├── portsview.go # -ss: TUI просмотр занятых портов
|
||||||
├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы
|
├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы
|
||||||
├── portcheck.go # -p: проверка доступности портов [WAF, build tag]
|
├── portcheck.go # -p: проверка доступности портов [WAF, build tag]
|
||||||
|
|
@ -93,6 +95,8 @@ sudo cp auspex /usr/local/bin/auspex
|
||||||
./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов
|
./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов
|
||||||
./auspex -dt # Docker Live TUI (аналог lazydocker)
|
./auspex -dt # Docker Live TUI (аналог lazydocker)
|
||||||
./auspex -fw # Firewall TUI (ufw/nftables/iptables)
|
./auspex -fw # Firewall TUI (ufw/nftables/iptables)
|
||||||
|
./auspex -au # Полный аудит-скан
|
||||||
|
./auspex -au nginx # Аудит только одной категории
|
||||||
./auspex -h # Справка
|
./auspex -h # Справка
|
||||||
./auspex -v # Версия
|
./auspex -v # Версия
|
||||||
```
|
```
|
||||||
|
|
|
||||||
518
audit.go
Normal file
518
audit.go
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// audit — комплексная диагностика сервера по категориям
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// В отличие от остальных модулей, это не "проверка одной вещи", а сквозной
|
||||||
|
// скан: systemd, nginx/Angie, Docker, сертификаты, диск/логи, сеть/DNS,
|
||||||
|
// firewall — плюс КРОСС-ПРОВЕРКИ между категориями (слушающие порты против
|
||||||
|
// firewall-правил, конфиг nginx против реальных сокетов, сертификат на диске
|
||||||
|
// против того, что реально отдаётся по TLS). Такие несостыковки — самый частый
|
||||||
|
// источник "необъяснимых" багов что на тренировочных стендах, что в проде.
|
||||||
|
//
|
||||||
|
// ПОЛНОСТЬЮ READ-ONLY: модуль ничего не меняет и не чинит — только собирает
|
||||||
|
// находки и печатает отчёт с рекомендациями, что проверить дальше руками.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type auFinding struct {
|
||||||
|
Category string
|
||||||
|
Severity string // "critical" | "warning" | "info"
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type auSocket struct {
|
||||||
|
Proto string
|
||||||
|
Port string
|
||||||
|
}
|
||||||
|
|
||||||
|
var auCategories = []string{"systemd", "nginx", "docker", "cert", "disk", "network", "firewall"}
|
||||||
|
|
||||||
|
var auAliases = map[string]string{
|
||||||
|
"svc": "systemd", "service": "systemd", "services": "systemd", "systemctl": "systemd",
|
||||||
|
"angie": "nginx", "proxy": "nginx", "reverse-proxy": "nginx",
|
||||||
|
"container": "docker", "containers": "docker",
|
||||||
|
"certs": "cert", "tls": "cert", "ssl": "cert", "certificate": "cert", "certificates": "cert",
|
||||||
|
"logs": "disk", "log": "disk", "space": "disk",
|
||||||
|
"dns": "network", "net": "network",
|
||||||
|
"fw": "firewall", "iptables": "firewall", "nftables": "firewall", "ufw": "firewall",
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAudit(filter string) error {
|
||||||
|
runners := map[string]func() []auFinding{
|
||||||
|
"systemd": auCheckSystemd,
|
||||||
|
"nginx": auCheckNginx,
|
||||||
|
"docker": auCheckDocker,
|
||||||
|
"cert": auCheckCerts,
|
||||||
|
"disk": auCheckDiskAndLogs,
|
||||||
|
"network": auCheckNetwork,
|
||||||
|
"firewall": auCheckFirewall,
|
||||||
|
}
|
||||||
|
|
||||||
|
order := auCategories
|
||||||
|
if filter != "" {
|
||||||
|
f := strings.ToLower(strings.TrimSpace(filter))
|
||||||
|
if canon, ok := auAliases[f]; ok {
|
||||||
|
f = canon
|
||||||
|
}
|
||||||
|
if _, ok := runners[f]; !ok {
|
||||||
|
return fmt.Errorf("неизвестная категория %q — доступны: %s", filter, strings.Join(auCategories, ", "))
|
||||||
|
}
|
||||||
|
order = []string{f}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s🔍 Auspex Audit — комплексная диагностика%s\n\n", cCyan, cReset)
|
||||||
|
|
||||||
|
var all []auFinding
|
||||||
|
for _, name := range order {
|
||||||
|
fmt.Printf("%s▶ %s...%s\n", cBlue, name, cReset)
|
||||||
|
all = append(all, runners[name]()...)
|
||||||
|
}
|
||||||
|
|
||||||
|
auPrintReport(all)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func auPrintReport(all []auFinding) {
|
||||||
|
var critical, warning, info []auFinding
|
||||||
|
for _, f := range all {
|
||||||
|
switch f.Severity {
|
||||||
|
case "critical":
|
||||||
|
critical = append(critical, f)
|
||||||
|
case "warning":
|
||||||
|
warning = append(warning, f)
|
||||||
|
default:
|
||||||
|
info = append(info, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
if len(critical) > 0 {
|
||||||
|
fmt.Printf("%s🎯 Наиболее вероятные причины проблемы (%d):%s\n", cRed, len(critical), cReset)
|
||||||
|
for _, f := range critical {
|
||||||
|
fmt.Printf(" 🔴 [%s] %s\n", f.Category, f.Message)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%sКритичных находок не обнаружено.%s\n\n", cGreen, cReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(warning) > 0 {
|
||||||
|
fmt.Printf("%s⚠️ Требует внимания (%d):%s\n", cYellow, len(warning), cReset)
|
||||||
|
for _, f := range warning {
|
||||||
|
fmt.Printf(" 🟡 [%s] %s\n", f.Category, f.Message)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(info) > 0 {
|
||||||
|
fmt.Printf("%sСправочно (%d):%s\n", cCyan, len(info), cReset)
|
||||||
|
for _, f := range info {
|
||||||
|
fmt.Printf(" ℹ️ [%s] %s\n", f.Category, f.Message)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Итого: %d критично, %d предупреждений, %d информационных\n", len(critical), len(warning), len(info))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("%sНапоминание: это подсказки, а не диагноз. Часть находок (например\n"+
|
||||||
|
"'порт слушает, но нет явного allow-правила') может быть ложной — не забывайте\n"+
|
||||||
|
"про default policy firewall'а и специфику конкретного стенда.%s\n", cYellow, cReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auListeningPorts — общий хелпер для nginx- и firewall-кросс-проверок ─────
|
||||||
|
|
||||||
|
func auListeningPorts() ([]auSocket, error) {
|
||||||
|
var socks []auSocket
|
||||||
|
for _, flag := range []string{"-tlnp", "-ulnp"} {
|
||||||
|
proto := "tcp"
|
||||||
|
if flag == "-ulnp" {
|
||||||
|
proto = "udp"
|
||||||
|
}
|
||||||
|
out, err := exec.Command("ss", flag).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, line := range strings.Split(string(out), "\n") {
|
||||||
|
if i == 0 || strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
port := ""
|
||||||
|
for _, f := range fields {
|
||||||
|
idx := strings.LastIndex(f, ":")
|
||||||
|
if idx == -1 || idx == len(f)-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, err := strconv.Atoi(f[idx+1:]); err == nil {
|
||||||
|
port = strconv.Itoa(p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
socks = append(socks, auSocket{Proto: proto, Port: port})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return socks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── systemd ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckSystemd() []auFinding {
|
||||||
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||||
|
return []auFinding{{"systemd", "info", "systemctl не найден — пропущено (не systemd-система?)"}}
|
||||||
|
}
|
||||||
|
raw, err := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain").Output()
|
||||||
|
if err != nil {
|
||||||
|
return []auFinding{{"systemd", "warning", fmt.Sprintf("не удалось получить список служб: %v", err)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []auFinding
|
||||||
|
failed := 0
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unit, active, sub := fields[0], fields[2], fields[3]
|
||||||
|
if active == "failed" {
|
||||||
|
failed++
|
||||||
|
out = append(out, auFinding{"systemd", "critical",
|
||||||
|
fmt.Sprintf("служба %s в состоянии failed (%s) — journalctl -u %s -n 50", unit, sub, unit)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed == 0 {
|
||||||
|
out = append(out, auFinding{"systemd", "info", "служб в состоянии failed не найдено"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nginx / Angie ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckNginx() []auFinding {
|
||||||
|
var out []auFinding
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, bin := range []string{"nginx", "angie"} {
|
||||||
|
if _, err := exec.LookPath(bin); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
|
||||||
|
testOut, testErr := exec.Command(bin, "-t").CombinedOutput()
|
||||||
|
if testErr != nil {
|
||||||
|
out = append(out, auFinding{"nginx", "critical",
|
||||||
|
fmt.Sprintf("'%s -t' провалилась: %s", bin, strings.TrimSpace(string(testOut)))})
|
||||||
|
} else {
|
||||||
|
out = append(out, auFinding{"nginx", "info", fmt.Sprintf("'%s -t': синтаксис конфигурации в порядке", bin)})
|
||||||
|
}
|
||||||
|
|
||||||
|
activeOut, _ := exec.Command("systemctl", "is-active", bin).Output()
|
||||||
|
if strings.TrimSpace(string(activeOut)) != "active" {
|
||||||
|
out = append(out, auFinding{"nginx", "critical",
|
||||||
|
fmt.Sprintf("%s не активен (systemctl is-active: %s)", bin, strings.TrimSpace(string(activeOut)))})
|
||||||
|
}
|
||||||
|
|
||||||
|
if dump, err := exec.Command(bin, "-T").Output(); err == nil {
|
||||||
|
out = append(out, auCheckNginxListenPorts(bin, string(dump))...)
|
||||||
|
out = append(out, auCheckNginxUpstreams(string(dump))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
out = append(out, auFinding{"nginx", "info", "nginx/angie не найдены на этом хосте — пропущено"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var auNginxListenRe = regexp.MustCompile(`listen\s+(?:\S+:)?(\d+)`)
|
||||||
|
|
||||||
|
func auCheckNginxListenPorts(bin, dump string) []auFinding {
|
||||||
|
var out []auFinding
|
||||||
|
ports := map[string]bool{}
|
||||||
|
for _, m := range auNginxListenRe.FindAllStringSubmatch(dump, -1) {
|
||||||
|
ports[m[1]] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
listening, _ := auListeningPorts()
|
||||||
|
listenSet := map[string]bool{}
|
||||||
|
for _, s := range listening {
|
||||||
|
listenSet[s.Port] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for port := range ports {
|
||||||
|
if !listenSet[port] {
|
||||||
|
out = append(out, auFinding{"nginx", "critical", fmt.Sprintf(
|
||||||
|
"%s настроен слушать порт %s, но по факту никто на нём не слушает — процесс не применил конфиг или упал после reload",
|
||||||
|
bin, port)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var auNginxUpstreamRe = regexp.MustCompile(`server\s+([\w.\-]+:\d+)\s*;`)
|
||||||
|
|
||||||
|
func auCheckNginxUpstreams(dump string) []auFinding {
|
||||||
|
var out []auFinding
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range auNginxUpstreamRe.FindAllStringSubmatch(dump, -1) {
|
||||||
|
addr := m[1]
|
||||||
|
if seen[addr] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[addr] = true
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, auFinding{"nginx", "critical",
|
||||||
|
fmt.Sprintf("upstream %s недоступен (%v) — бэкенд не отвечает или указан неверный адрес", addr, err)})
|
||||||
|
} else {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Docker ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckDocker() []auFinding {
|
||||||
|
if err := dcCheckDockerAvailable(); err != nil {
|
||||||
|
return []auFinding{{"docker", "info", fmt.Sprintf("Docker недоступен — пропущено (%v)", err)}}
|
||||||
|
}
|
||||||
|
containers, err := dcGetContainers()
|
||||||
|
if err != nil {
|
||||||
|
return []auFinding{{"docker", "warning", fmt.Sprintf("не удалось получить список контейнеров: %v", err)}}
|
||||||
|
}
|
||||||
|
if len(containers) == 0 {
|
||||||
|
return []auFinding{{"docker", "info", "контейнеров не найдено"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []auFinding
|
||||||
|
for _, c := range containers {
|
||||||
|
switch {
|
||||||
|
case c.Status == "running" && c.Health == "unhealthy":
|
||||||
|
out = append(out, auFinding{"docker", "critical",
|
||||||
|
fmt.Sprintf("контейнер %s запущен, но healthcheck сообщает unhealthy — docker logs %s", c.Name, c.Name)})
|
||||||
|
case c.Status == "restarting":
|
||||||
|
out = append(out, auFinding{"docker", "critical",
|
||||||
|
fmt.Sprintf("контейнер %s в состоянии restarting (похоже на crash-loop) — docker logs %s", c.Name, c.Name)})
|
||||||
|
case c.Status == "exited" || c.Status == "dead":
|
||||||
|
out = append(out, auFinding{"docker", "critical",
|
||||||
|
fmt.Sprintf("контейнер %s остановлен (%s) — docker logs %s", c.Name, c.Status, c.Name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, auFinding{"docker", "info", fmt.Sprintf("все %d контейнер(ов) в порядке", len(containers))})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Сертификаты / TLS ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckCerts() []auFinding {
|
||||||
|
paths, err := ccFindCerts(cfgSSLDir)
|
||||||
|
if err != nil {
|
||||||
|
return []auFinding{{"cert", "warning", fmt.Sprintf("не удалось просканировать %s: %v", cfgSSLDir, err)}}
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return []auFinding{{"cert", "info", fmt.Sprintf("сертификаты в %s не найдены", cfgSSLDir)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []auFinding
|
||||||
|
var newest ccCertInfo
|
||||||
|
haveNewest := false
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
info, err := ccParseCert(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case info.DaysLeft < 0:
|
||||||
|
out = append(out, auFinding{"cert", "critical",
|
||||||
|
fmt.Sprintf("%s (CN=%s) истёк %d дн. назад", info.Path, info.CommonName, -info.DaysLeft)})
|
||||||
|
case info.DaysLeft <= cfgCertCriticalDays:
|
||||||
|
out = append(out, auFinding{"cert", "critical",
|
||||||
|
fmt.Sprintf("%s (CN=%s) истекает через %d дн.", info.Path, info.CommonName, info.DaysLeft)})
|
||||||
|
case info.DaysLeft <= cfgCertWarningDays:
|
||||||
|
out = append(out, auFinding{"cert", "warning",
|
||||||
|
fmt.Sprintf("%s (CN=%s) истекает через %d дн.", info.Path, info.CommonName, info.DaysLeft)})
|
||||||
|
}
|
||||||
|
if !haveNewest || info.NotAfter.After(newest.NotAfter) {
|
||||||
|
newest = info
|
||||||
|
haveNewest = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if haveNewest {
|
||||||
|
if served, err := auFetchServedCert("127.0.0.1:443", 2*time.Second); err == nil {
|
||||||
|
if served.NotAfter.Before(newest.NotAfter.Add(-24 * time.Hour)) {
|
||||||
|
out = append(out, auFinding{"cert", "critical", fmt.Sprintf(
|
||||||
|
"на диске есть сертификат новее (истекает %s), чем реально отдаётся по TLS на :443 (истекает %s) — похоже, сервис не перечитал сертификат после обновления/выпуска",
|
||||||
|
newest.NotAfter.Format("2006-01-02"), served.NotAfter.Format("2006-01-02"))})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, auFinding{"cert", "info", fmt.Sprintf("проверено %d сертификат(ов), проблем не найдено", len(paths))})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func auFetchServedCert(addr string, timeout time.Duration) (*x509.Certificate, error) {
|
||||||
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return nil, fmt.Errorf("сертификат не получен в TLS-хендшейке")
|
||||||
|
}
|
||||||
|
return certs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Диск и логи ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckDiskAndLogs() []auFinding {
|
||||||
|
var out []auFinding
|
||||||
|
|
||||||
|
if dfOut, err := exec.Command("df", "-P").Output(); err == nil {
|
||||||
|
for i, line := range strings.Split(string(dfOut), "\n") {
|
||||||
|
if i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs, usePct, mount := fields[0], fields[4], fields[5]
|
||||||
|
if strings.Contains(fs, "tmpfs") || strings.Contains(fs, "overlay") || strings.Contains(fs, "squashfs") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSuffix(usePct, "%"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case n >= 95:
|
||||||
|
out = append(out, auFinding{"disk", "critical", fmt.Sprintf("%s заполнен на %d%% — критично мало места", mount, n)})
|
||||||
|
case n >= 85:
|
||||||
|
out = append(out, auFinding{"disk", "warning", fmt.Sprintf("%s заполнен на %d%%", mount, n)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := fdScan()
|
||||||
|
var leaked int64
|
||||||
|
for _, e := range entries {
|
||||||
|
leaked += e.SizeBytes
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case leaked > 500*1024*1024:
|
||||||
|
out = append(out, auFinding{"disk", "critical",
|
||||||
|
fmt.Sprintf("удалённые-но-открытые файлы держат %s места (см. -fd для деталей)", fdHumanSize(leaked))})
|
||||||
|
case leaked > 50*1024*1024:
|
||||||
|
out = append(out, auFinding{"disk", "warning",
|
||||||
|
fmt.Sprintf("удалённые-но-открытые файлы держат %s (см. -fd)", fdHumanSize(leaked))})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, auFinding{"disk", "info", "диск и файловые дескрипторы в порядке"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Сеть / DNS ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckNetwork() []auFinding {
|
||||||
|
var out []auFinding
|
||||||
|
|
||||||
|
data, err := os.ReadFile("/etc/resolv.conf")
|
||||||
|
if err != nil || !strings.Contains(string(data), "nameserver") {
|
||||||
|
out = append(out, auFinding{"network", "warning", "в /etc/resolv.conf не найдено ни одного nameserver — DNS может не работать"})
|
||||||
|
} else {
|
||||||
|
out = append(out, auFinding{"network", "info", "nameserver в /etc/resolv.conf присутствует"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if routeOut, err := exec.Command("ip", "route", "show", "default").Output(); err == nil {
|
||||||
|
if strings.TrimSpace(string(routeOut)) == "" {
|
||||||
|
out = append(out, auFinding{"network", "warning",
|
||||||
|
"default route не найден — если сервер должен иметь исходящий доступ, это проблема (в изолированных стендах может быть ожидаемо)"})
|
||||||
|
} else {
|
||||||
|
out = append(out, auFinding{"network", "info", "default route настроен"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := net.LookupHost("localhost"); err != nil {
|
||||||
|
out = append(out, auFinding{"network", "warning",
|
||||||
|
fmt.Sprintf("не удалось разрешить даже 'localhost': %v — похоже, сломан резолвинг на уровне NSS/glibc", err)})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Firewall ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func auCheckFirewall() []auFinding {
|
||||||
|
backend, err := fwDetectBackend()
|
||||||
|
if err != nil {
|
||||||
|
return []auFinding{{"firewall", "warning", fmt.Sprintf("не удалось определить firewall backend: %v", err)}}
|
||||||
|
}
|
||||||
|
rules, err := backend.ListRules()
|
||||||
|
if err != nil {
|
||||||
|
return []auFinding{{"firewall", "warning", fmt.Sprintf("не удалось получить правила (%s): %v", backend.Name(), err)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []auFinding{{"firewall", "info", fmt.Sprintf("backend: %s, правил в списке: %d", backend.Name(), len(rules))}}
|
||||||
|
|
||||||
|
listening, _ := auListeningPorts()
|
||||||
|
listenSet := map[string]bool{}
|
||||||
|
for _, s := range listening {
|
||||||
|
listenSet[s.Port] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Port == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
action := strings.ToUpper(r.Action)
|
||||||
|
if action == "ACCEPT" || action == "ALLOW" {
|
||||||
|
allowed[r.Port] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for port := range allowed {
|
||||||
|
if !listenSet[port] {
|
||||||
|
out = append(out, auFinding{"firewall", "warning", fmt.Sprintf(
|
||||||
|
"правило разрешает порт %s, но на нём никто не слушает — либо сервис упал, либо правило устарело", port)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for port := range listenSet {
|
||||||
|
if port == "22" {
|
||||||
|
continue // почти всегда разрешён отдельно/default policy — шумный false-positive
|
||||||
|
}
|
||||||
|
if !allowed[port] {
|
||||||
|
out = append(out, auFinding{"firewall", "warning", fmt.Sprintf(
|
||||||
|
"порт %s слушает, но явного allow-правила для него не найдено — проверьте доступность снаружи (может маскироваться default policy)", port)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
21
main.go
21
main.go
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const appVersion = "1.4.0"
|
const appVersion = "1.5.0"
|
||||||
|
|
||||||
// Цвета терминала (общие для всех модулей)
|
// Цвета терминала (общие для всех модулей)
|
||||||
const (
|
const (
|
||||||
|
|
@ -123,6 +123,16 @@ func runByFlag(flag string, extraArgs []string) {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "-au":
|
||||||
|
category := ""
|
||||||
|
if len(extraArgs) > 0 {
|
||||||
|
category = extraArgs[0]
|
||||||
|
}
|
||||||
|
if err := runAudit(category); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
case "-v", "--version":
|
case "-v", "--version":
|
||||||
fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant)
|
fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant)
|
||||||
|
|
||||||
|
|
@ -197,6 +207,13 @@ func showMenu() {
|
||||||
if err := runFirewallTUI(); err != nil {
|
if err := runFirewallTUI(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset)
|
fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset)
|
||||||
}
|
}
|
||||||
|
case "12", "-au", "au":
|
||||||
|
fmt.Printf(" Категория (Enter = все: systemd/nginx/docker/cert/disk/network/firewall): ")
|
||||||
|
catInput, _ := reader.ReadString('\n')
|
||||||
|
catInput = strings.TrimSpace(catInput)
|
||||||
|
if err := runAudit(catInput); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset)
|
||||||
|
}
|
||||||
case "0", "q", "quit", "exit":
|
case "0", "q", "quit", "exit":
|
||||||
fmt.Println("Выход.")
|
fmt.Println("Выход.")
|
||||||
return
|
return
|
||||||
|
|
@ -224,6 +241,7 @@ func printMenuOptions() {
|
||||||
fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset)
|
fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset)
|
||||||
fmt.Printf(" %s10%s) %s-dt%s Docker Live — TUI (аналог lazydocker)\n", cGreen, cReset, cCyan, cReset)
|
fmt.Printf(" %s10%s) %s-dt%s Docker Live — TUI (аналог lazydocker)\n", cGreen, cReset, cCyan, cReset)
|
||||||
fmt.Printf(" %s11%s) %s-fw%s Firewall — TUI (ufw/nftables/iptables)\n", cGreen, cReset, cCyan, cReset)
|
fmt.Printf(" %s11%s) %s-fw%s Firewall — TUI (ufw/nftables/iptables)\n", cGreen, cReset, cCyan, cReset)
|
||||||
|
fmt.Printf(" %s12%s) %s-au%s Audit — комплексная диагностика [категория]\n", cGreen, cReset, cCyan, cReset)
|
||||||
fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset)
|
fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset)
|
||||||
fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset)
|
fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset)
|
||||||
fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w <sid>%s\n\n", cYellow, cReset)
|
fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w <sid>%s\n\n", cYellow, cReset)
|
||||||
|
|
@ -260,6 +278,7 @@ func printUsage() {
|
||||||
fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd")
|
fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd")
|
||||||
fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt")
|
fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt")
|
||||||
fmt.Printf(" %-12s Firewall — интерактивный TUI (ufw/nftables/iptables, dry-run 'D')\n", "-fw")
|
fmt.Printf(" %-12s Firewall — интерактивный TUI (ufw/nftables/iptables, dry-run 'D')\n", "-fw")
|
||||||
|
fmt.Printf(" %-12s Комплексный аудит-скан по категориям [категория] (read-only)\n", "-au")
|
||||||
fmt.Printf(" %-12s Показать версию\n", "-v")
|
fmt.Printf(" %-12s Показать версию\n", "-v")
|
||||||
fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
|
fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
|
||||||
fmt.Printf("Без аргументов запускается интерактивное меню.\n")
|
fmt.Printf("Без аргументов запускается интерактивное меню.\n")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue