Files
dip-ids/internal/report/pdf.go
T
2026-04-12 22:26:26 +04:00

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]) + "..."
}