441 lines
14 KiB
Go
441 lines
14 KiB
Go
package report
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"dip-ids/internal/storage"
|
|
|
|
"github.com/johnfercher/maroto/v2"
|
|
"github.com/johnfercher/maroto/v2/pkg/components/col"
|
|
"github.com/johnfercher/maroto/v2/pkg/components/line"
|
|
"github.com/johnfercher/maroto/v2/pkg/components/row"
|
|
"github.com/johnfercher/maroto/v2/pkg/components/text"
|
|
"github.com/johnfercher/maroto/v2/pkg/config"
|
|
"github.com/johnfercher/maroto/v2/pkg/consts/align"
|
|
"github.com/johnfercher/maroto/v2/pkg/consts/fontstyle"
|
|
"github.com/johnfercher/maroto/v2/pkg/core"
|
|
"github.com/johnfercher/maroto/v2/pkg/props"
|
|
"github.com/johnfercher/maroto/v2/pkg/repository"
|
|
)
|
|
|
|
// Data данные для генерации отчёта
|
|
type Data struct {
|
|
GeneratedAt time.Time
|
|
Session *storage.CaptureSession
|
|
Alerts []storage.Alert
|
|
BySeverity []storage.SeverityCount
|
|
ByType []storage.TypeCount
|
|
TopIPs []storage.IPCount
|
|
TrafficByProto []storage.ProtocolCount
|
|
RecentStats []storage.PacketStat
|
|
}
|
|
|
|
// Пути к шрифту DejaVu Sans с поддержкой кириллицы
|
|
var cyrillicFontCandidates = []string{
|
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/dejavu-sans/DejaVuSans.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
|
}
|
|
|
|
var cyrillicBoldCandidates = []string{
|
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/dejavu-sans/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
|
}
|
|
|
|
func findFont(candidates []string) string {
|
|
for _, p := range candidates {
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
const fontFamily = "DejaVu"
|
|
|
|
// Generate создаёт PDF-отчёт и возвращает байты
|
|
func Generate(d Data) ([]byte, error) {
|
|
normalFont := findFont(cyrillicFontCandidates)
|
|
boldFont := findFont(cyrillicBoldCandidates)
|
|
|
|
builder := config.NewBuilder().
|
|
WithLeftMargin(15).
|
|
WithRightMargin(15).
|
|
WithTopMargin(15).
|
|
WithBottomMargin(15)
|
|
|
|
if normalFont != "" && boldFont != "" {
|
|
builder = builder.
|
|
WithCustomFonts([]*entity.CustomFont{
|
|
{Family: fontFamily, Style: fontstyle.Normal, File: normalFont},
|
|
{Family: fontFamily, Style: fontstyle.Bold, File: boldFont},
|
|
{Family: fontFamily, Style: fontstyle.Italic, File: normalFont},
|
|
{Family: fontFamily, Style: fontstyle.BoldItalic, File: boldFont},
|
|
}).
|
|
WithDefaultFont(&props.Font{Family: fontFamily, Size: 9})
|
|
} else {
|
|
log.Println("[report] DejaVu font not found, Cyrillic may not render. Install ttf-dejavu.")
|
|
}
|
|
|
|
m := maroto.New(builder.Build())
|
|
|
|
// ── Заголовок ─────────────────────────────────────────────────────────────
|
|
m.AddRows(buildHeader(d)...)
|
|
m.AddRow(3, line.NewCol(12, props.Line{Color: &props.Color{Red: 37, Green: 99, Blue: 235}}))
|
|
m.AddRows(buildMeta(d)...)
|
|
m.AddRow(5)
|
|
|
|
// ── Сводка по критичности ─────────────────────────────────────────────────
|
|
m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...)
|
|
m.AddRows(buildSeverityTable(d.BySeverity)...)
|
|
m.AddRow(4)
|
|
|
|
// ── Типы угроз ────────────────────────────────────────────────────────────
|
|
m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...)
|
|
m.AddRows(buildTypeTable(d.ByType)...)
|
|
m.AddRow(4)
|
|
|
|
// ── Топ источников ────────────────────────────────────────────────────────
|
|
if len(d.TopIPs) > 0 {
|
|
m.AddRows(buildSectionTitle("3. Топ источников атак")...)
|
|
m.AddRows(buildTopIPsTable(d.TopIPs)...)
|
|
m.AddRow(4)
|
|
}
|
|
|
|
// ── Статистика трафика по протоколам ──────────────────────────────────────
|
|
if len(d.TrafficByProto) > 0 {
|
|
m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...)
|
|
m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...)
|
|
m.AddRow(4)
|
|
}
|
|
|
|
// ── Лог трафика (последние записи) ────────────────────────────────────────
|
|
if len(d.RecentStats) > 0 {
|
|
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", len(d.RecentStats)))...)
|
|
m.AddRows(buildTrafficLogHeader()...)
|
|
for i, s := range d.RecentStats {
|
|
if i >= 100 {
|
|
break
|
|
}
|
|
m.AddRows(buildTrafficLogRow(s)...)
|
|
}
|
|
m.AddRow(4)
|
|
}
|
|
|
|
// ── Журнал инцидентов ─────────────────────────────────────────────────────
|
|
sectionNum := 6
|
|
if len(d.TrafficByProto) == 0 {
|
|
sectionNum = 4
|
|
}
|
|
m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...)
|
|
m.AddRows(buildAlertsTableHeader()...)
|
|
for i, a := range d.Alerts {
|
|
if i >= 200 {
|
|
break
|
|
}
|
|
m.AddRows(buildAlertRow(a)...)
|
|
}
|
|
|
|
doc, err := m.Generate()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate pdf: %w", err)
|
|
}
|
|
return doc.GetBytes(), nil
|
|
}
|
|
|
|
// ── Секции отчёта ─────────────────────────────────────────────────────────────
|
|
|
|
func buildHeader(d Data) []core.Row {
|
|
return []core.Row{
|
|
row.New(14).Add(
|
|
col.New(8).Add(
|
|
text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{
|
|
Size: 16,
|
|
Style: fontstyle.Bold,
|
|
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
|
|
}),
|
|
text.New("Отчёт об инцидентах сетевой безопасности", props.Text{
|
|
Size: 9,
|
|
Top: 8,
|
|
Color: &props.Color{Red: 100, Green: 100, Blue: 100},
|
|
}),
|
|
),
|
|
col.New(4).Add(
|
|
text.New("DIP-IDS v1.0", props.Text{
|
|
Size: 9,
|
|
Align: align.Right,
|
|
Style: fontstyle.Bold,
|
|
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
|
|
}),
|
|
text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{
|
|
Size: 8,
|
|
Top: 5,
|
|
Align: align.Right,
|
|
Color: &props.Color{Red: 120, Green: 120, Blue: 120},
|
|
}),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
func buildMeta(d Data) []core.Row {
|
|
source, mode, duration, pkts, bytes := "—", "—", "—", "—", "—"
|
|
if d.Session != nil {
|
|
source = d.Session.Source
|
|
mode = modeLabel(d.Session.Mode)
|
|
pkts = fmt.Sprintf("%d", d.Session.TotalPkts)
|
|
bytes = humanBytes(d.Session.TotalBytes)
|
|
if d.Session.EndedAt != nil {
|
|
duration = fmt.Sprintf("%.0f сек", d.Session.EndedAt.Sub(d.Session.CreatedAt).Seconds())
|
|
} else {
|
|
duration = "активна"
|
|
}
|
|
}
|
|
return []core.Row{
|
|
row.New(5).Add(
|
|
col.New(3).Add(metaCell("Источник", source)),
|
|
col.New(3).Add(metaCell("Режим", mode)),
|
|
col.New(3).Add(metaCell("Длительность", duration)),
|
|
col.New(3).Add(metaCell("Пакетов / Объём", pkts+" / "+bytes)),
|
|
),
|
|
}
|
|
}
|
|
|
|
func metaCell(label, value string) core.Component {
|
|
return text.New(label+": "+value, props.Text{
|
|
Size: 8,
|
|
Color: &props.Color{Red: 80, Green: 80, Blue: 80},
|
|
})
|
|
}
|
|
|
|
func buildSectionTitle(title string) []core.Row {
|
|
return []core.Row{
|
|
row.New(8).Add(
|
|
col.New(12).Add(
|
|
text.New(title, props.Text{
|
|
Size: 11,
|
|
Style: fontstyle.Bold,
|
|
Color: &props.Color{Red: 37, Green: 99, Blue: 235},
|
|
}),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ── Таблицы сводки ────────────────────────────────────────────────────────────
|
|
|
|
func buildSeverityTable(data []storage.SeverityCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 6, label: "Уровень критичности"},
|
|
{w: 6, label: "Количество алертов"},
|
|
})}
|
|
counts := map[string]int64{}
|
|
for _, s := range data {
|
|
counts[s.Severity] = s.Count
|
|
}
|
|
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
|
rows = append(rows, row.New(6).Add(
|
|
col.New(6).Add(text.New(severityLabel(sev), props.Text{
|
|
Size: 9, Style: fontstyle.Bold, Color: severityColor(sev),
|
|
})),
|
|
col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{Size: 9})),
|
|
))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildTypeTable(data []storage.TypeCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 6, label: "Тип атаки"},
|
|
{w: 6, label: "Количество"},
|
|
})}
|
|
for _, t := range data {
|
|
rows = append(rows, row.New(6).Add(
|
|
col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9})),
|
|
col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9})),
|
|
))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildTopIPsTable(data []storage.IPCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 1, label: "#"},
|
|
{w: 7, label: "IP-адрес источника"},
|
|
{w: 4, label: "Алертов"},
|
|
})}
|
|
for i, ip := range data {
|
|
rows = append(rows, row.New(6).Add(
|
|
col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8})),
|
|
col.New(7).Add(text.New(ip.SrcIP, props.Text{Size: 8})),
|
|
col.New(4).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8})),
|
|
))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// ── Таблица трафика по протоколам ─────────────────────────────────────────────
|
|
|
|
func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 4, label: "Протокол"},
|
|
{w: 4, label: "Пакетов"},
|
|
{w: 4, label: "Объём трафика"},
|
|
})}
|
|
for _, p := range data {
|
|
rows = append(rows, row.New(6).Add(
|
|
col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold})),
|
|
col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9})),
|
|
col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9})),
|
|
))
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// ── Журнал трафика ────────────────────────────────────────────────────────────
|
|
|
|
func buildTrafficLogHeader() []core.Row {
|
|
return []core.Row{tableHeaderRow([]colDef{
|
|
{w: 3, label: "Время"},
|
|
{w: 2, label: "Протокол"},
|
|
{w: 4, label: "Источник"},
|
|
{w: 3, label: "Назначение"},
|
|
})}
|
|
}
|
|
|
|
func buildTrafficLogRow(s storage.PacketStat) []core.Row {
|
|
return []core.Row{
|
|
row.New(5).Add(
|
|
col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7})),
|
|
col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold})),
|
|
col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7})),
|
|
col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7})),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ── Журнал инцидентов ─────────────────────────────────────────────────────────
|
|
|
|
func buildAlertsTableHeader() []core.Row {
|
|
return []core.Row{tableHeaderRow([]colDef{
|
|
{w: 3, label: "Дата/Время"},
|
|
{w: 2, label: "Тип"},
|
|
{w: 2, label: "Критичность"},
|
|
{w: 2, label: "Источник"},
|
|
{w: 3, label: "Описание"},
|
|
})}
|
|
}
|
|
|
|
func buildAlertRow(a storage.Alert) []core.Row {
|
|
return []core.Row{
|
|
row.New(6).Add(
|
|
col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04:05"), props.Text{Size: 7})),
|
|
col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7})),
|
|
col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{
|
|
Size: 7, Style: fontstyle.Bold, Color: severityColor(a.Severity),
|
|
})),
|
|
col.New(2).Add(text.New(a.SrcIP, props.Text{Size: 7})),
|
|
col.New(3).Add(text.New(truncate(a.Description, 60), props.Text{Size: 7})),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ── Вспомогательные типы и функции ───────────────────────────────────────────
|
|
|
|
type colDef struct {
|
|
w int
|
|
label string
|
|
}
|
|
|
|
func tableHeaderRow(cols []colDef) core.Row {
|
|
cells := make([]core.Col, 0, len(cols))
|
|
for _, c := range cols {
|
|
cells = append(cells, col.New(c.w).Add(
|
|
text.New(c.label, props.Text{
|
|
Size: 9,
|
|
Style: fontstyle.Bold,
|
|
Color: &props.Color{Red: 255, Green: 255, Blue: 255},
|
|
}),
|
|
))
|
|
}
|
|
return row.New(7).Add(cells...)
|
|
}
|
|
|
|
func severityLabel(s string) string {
|
|
switch s {
|
|
case "critical":
|
|
return "Критический"
|
|
case "high":
|
|
return "Высокий"
|
|
case "medium":
|
|
return "Средний"
|
|
case "low":
|
|
return "Низкий"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
func severityColor(s string) *props.Color {
|
|
switch s {
|
|
case "critical":
|
|
return &props.Color{Red: 185, Green: 28, Blue: 28}
|
|
case "high":
|
|
return &props.Color{Red: 194, Green: 65, Blue: 12}
|
|
case "medium":
|
|
return &props.Color{Red: 161, Green: 98, Blue: 7}
|
|
default:
|
|
return &props.Color{Red: 21, Green: 128, Blue: 61}
|
|
}
|
|
}
|
|
|
|
func typeLabel(t string) string {
|
|
switch t {
|
|
case "port_scan":
|
|
return "Port Scan"
|
|
case "brute_force":
|
|
return "Brute Force"
|
|
case "dos":
|
|
return "DoS/DDoS"
|
|
case "arp_spoof":
|
|
return "ARP Spoof"
|
|
case "dns_anomaly":
|
|
return "DNS Anomaly"
|
|
default:
|
|
return t
|
|
}
|
|
}
|
|
|
|
func modeLabel(m string) string {
|
|
if m == "live" {
|
|
return "Live захват"
|
|
}
|
|
return "PCAP файл"
|
|
}
|
|
|
|
func humanBytes(b int64) string {
|
|
switch {
|
|
case b >= 1<<30:
|
|
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
|
|
case b >= 1<<20:
|
|
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
|
|
case b >= 1<<10:
|
|
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
|
|
default:
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len([]rune(s)) <= n {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:n-3]) + "..."
|
|
}
|