78e5c90ed0
also small various features
520 lines
15 KiB
Go
520 lines
15 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/border"
|
|
"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
|
|
}
|
|
|
|
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"
|
|
|
|
// цветовая палитра
|
|
var (
|
|
colorBlue = &props.Color{Red: 37, Green: 99, Blue: 235}
|
|
colorBlueDark = &props.Color{Red: 30, Green: 64, Blue: 175}
|
|
colorWhite = &props.Color{Red: 255, Green: 255, Blue: 255}
|
|
colorGray100 = &props.Color{Red: 248, Green: 250, Blue: 252}
|
|
colorGray200 = &props.Color{Red: 226, Green: 232, Blue: 240}
|
|
colorGray500 = &props.Color{Red: 100, Green: 116, Blue: 139}
|
|
colorGray700 = &props.Color{Red: 55, Green: 65, Blue: 81}
|
|
)
|
|
|
|
// 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 != "" {
|
|
fonts, err := repository.New().
|
|
AddUTF8Font(fontFamily, fontstyle.Normal, normalFont).
|
|
AddUTF8Font(fontFamily, fontstyle.Bold, boldFont).
|
|
AddUTF8Font(fontFamily, fontstyle.Italic, normalFont).
|
|
AddUTF8Font(fontFamily, fontstyle.BoldItalic, boldFont).
|
|
Load()
|
|
if err != nil {
|
|
log.Printf("[report] failed to load DejaVu font: %v", err)
|
|
} else {
|
|
builder = builder.
|
|
WithCustomFonts(fonts).
|
|
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(2, line.NewCol(12, props.Line{Color: colorBlue, Thickness: 0.5}))
|
|
m.AddRows(buildMeta(d)...)
|
|
m.AddRow(6)
|
|
|
|
// ── Сводка по критичности ─────────────────────────────────────────────────
|
|
m.AddRows(buildSectionTitle("1. Сводка по уровням критичности")...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildSeverityTable(d.BySeverity)...)
|
|
m.AddRow(6)
|
|
|
|
// ── Типы угроз ────────────────────────────────────────────────────────────
|
|
m.AddRows(buildSectionTitle("2. Типы обнаруженных угроз")...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildTypeTable(d.ByType)...)
|
|
m.AddRow(6)
|
|
|
|
// ── Топ источников ────────────────────────────────────────────────────────
|
|
if len(d.TopIPs) > 0 {
|
|
m.AddRows(buildSectionTitle("3. Топ источников атак")...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildTopIPsTable(d.TopIPs)...)
|
|
m.AddRow(6)
|
|
}
|
|
|
|
// ── Статистика трафика по протоколам ──────────────────────────────────────
|
|
if len(d.TrafficByProto) > 0 {
|
|
m.AddRows(buildSectionTitle("4. Статистика трафика по протоколам")...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildTrafficProtoTable(d.TrafficByProto)...)
|
|
m.AddRow(6)
|
|
}
|
|
|
|
// ── Лог трафика (последние записи) ────────────────────────────────────────
|
|
if len(d.RecentStats) > 0 {
|
|
limit := len(d.RecentStats)
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
m.AddRows(buildSectionTitle(fmt.Sprintf("5. Журнал трафика (последние %d записей)", limit))...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildTrafficLogHeader()...)
|
|
for i, s := range d.RecentStats {
|
|
if i >= 100 {
|
|
break
|
|
}
|
|
m.AddRows(buildTrafficLogRow(s, i)...)
|
|
}
|
|
m.AddRow(6)
|
|
}
|
|
|
|
// ── Журнал инцидентов ─────────────────────────────────────────────────────
|
|
sectionNum := 6
|
|
if len(d.TrafficByProto) == 0 {
|
|
sectionNum = 4
|
|
}
|
|
m.AddRows(buildSectionTitle(fmt.Sprintf("%d. Журнал инцидентов", sectionNum))...)
|
|
m.AddRow(2)
|
|
m.AddRows(buildAlertsTableHeader()...)
|
|
for i, a := range d.Alerts {
|
|
if i >= 200 {
|
|
break
|
|
}
|
|
m.AddRows(buildAlertRow(a, i)...)
|
|
}
|
|
|
|
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(10).Add(
|
|
col.New(8).Add(
|
|
text.New("СИСТЕМА ОБНАРУЖЕНИЯ ВТОРЖЕНИЙ", props.Text{
|
|
Size: 14,
|
|
Style: fontstyle.Bold,
|
|
Color: colorBlue,
|
|
Top: 1,
|
|
}),
|
|
text.New("Отчёт об инцидентах сетевой безопасности", props.Text{
|
|
Size: 9,
|
|
Top: 7,
|
|
Color: colorGray500,
|
|
}),
|
|
),
|
|
col.New(4).Add(
|
|
text.New("DIP-IDS v1.0", props.Text{
|
|
Size: 9,
|
|
Align: align.Right,
|
|
Style: fontstyle.Bold,
|
|
Color: colorBlue,
|
|
Top: 1,
|
|
}),
|
|
text.New(fmt.Sprintf("Дата: %s", d.GeneratedAt.Format("02.01.2006 15:04:05")), props.Text{
|
|
Size: 8,
|
|
Top: 6,
|
|
Align: align.Right,
|
|
Color: colorGray500,
|
|
}),
|
|
),
|
|
),
|
|
}
|
|
}
|
|
|
|
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 = "активна"
|
|
}
|
|
}
|
|
r := row.New(8).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)),
|
|
)
|
|
r.WithStyle(&props.Cell{
|
|
BackgroundColor: colorGray100,
|
|
BorderColor: colorGray200,
|
|
BorderType: border.Full,
|
|
BorderThickness: 0.3,
|
|
})
|
|
return []core.Row{r}
|
|
}
|
|
|
|
func metaCell(label, value string) core.Component {
|
|
return text.New(label+": "+value, props.Text{
|
|
Size: 8,
|
|
Left: 2,
|
|
Top: 2,
|
|
Color: colorGray700,
|
|
})
|
|
}
|
|
|
|
func buildSectionTitle(title string) []core.Row {
|
|
r := row.New(8).Add(
|
|
col.New(12).Add(
|
|
text.New(title, props.Text{
|
|
Size: 10,
|
|
Style: fontstyle.Bold,
|
|
Color: colorBlueDark,
|
|
Left: 1,
|
|
Top: 2,
|
|
}),
|
|
),
|
|
)
|
|
r.WithStyle(&props.Cell{
|
|
BackgroundColor: &props.Color{Red: 239, Green: 246, Blue: 255},
|
|
BorderColor: colorBlue,
|
|
BorderType: border.Left,
|
|
BorderThickness: 2,
|
|
})
|
|
return []core.Row{r}
|
|
}
|
|
|
|
// ── Таблицы ───────────────────────────────────────────────────────────────────
|
|
|
|
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 i, sev := range []string{"critical", "high", "medium", "low"} {
|
|
r := row.New(7).Add(
|
|
col.New(6).Add(text.New(severityLabel(sev), props.Text{
|
|
Size: 9,
|
|
Style: fontstyle.Bold,
|
|
Color: severityColor(sev),
|
|
Left: 2,
|
|
Top: 2,
|
|
})),
|
|
col.New(6).Add(text.New(fmt.Sprintf("%d", counts[sev]), props.Text{
|
|
Size: 9,
|
|
Left: 2,
|
|
Top: 2,
|
|
})),
|
|
)
|
|
r.WithStyle(dataRowStyle(i))
|
|
rows = append(rows, r)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildTypeTable(data []storage.TypeCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 6, label: "Тип атаки"},
|
|
{w: 6, label: "Количество"},
|
|
})}
|
|
for i, t := range data {
|
|
r := row.New(7).Add(
|
|
col.New(6).Add(text.New(typeLabel(t.Type), props.Text{Size: 9, Left: 2, Top: 2})),
|
|
col.New(6).Add(text.New(fmt.Sprintf("%d", t.Count), props.Text{Size: 9, Left: 2, Top: 2})),
|
|
)
|
|
r.WithStyle(dataRowStyle(i))
|
|
rows = append(rows, r)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildTopIPsTable(data []storage.IPCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 1, label: "#"},
|
|
{w: 8, label: "IP-адрес источника"},
|
|
{w: 3, label: "Алертов"},
|
|
})}
|
|
for i, ip := range data {
|
|
r := row.New(7).Add(
|
|
col.New(1).Add(text.New(fmt.Sprintf("%d", i+1), props.Text{Size: 8, Left: 2, Top: 2})),
|
|
col.New(8).Add(text.New(ip.SrcIP, props.Text{Size: 8, Left: 2, Top: 2})),
|
|
col.New(3).Add(text.New(fmt.Sprintf("%d", ip.Count), props.Text{Size: 8, Left: 2, Top: 2})),
|
|
)
|
|
r.WithStyle(dataRowStyle(i))
|
|
rows = append(rows, r)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildTrafficProtoTable(data []storage.ProtocolCount) []core.Row {
|
|
rows := []core.Row{tableHeaderRow([]colDef{
|
|
{w: 4, label: "Протокол"},
|
|
{w: 4, label: "Пакетов"},
|
|
{w: 4, label: "Объём трафика"},
|
|
})}
|
|
for i, p := range data {
|
|
r := row.New(7).Add(
|
|
col.New(4).Add(text.New(p.Protocol, props.Text{Size: 9, Style: fontstyle.Bold, Left: 2, Top: 2})),
|
|
col.New(4).Add(text.New(fmt.Sprintf("%d", p.Count), props.Text{Size: 9, Left: 2, Top: 2})),
|
|
col.New(4).Add(text.New(humanBytes(p.Bytes), props.Text{Size: 9, Left: 2, Top: 2})),
|
|
)
|
|
r.WithStyle(dataRowStyle(i))
|
|
rows = append(rows, r)
|
|
}
|
|
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, idx int) []core.Row {
|
|
r := row.New(6).Add(
|
|
col.New(3).Add(text.New(s.CreatedAt.Format("02.01 15:04:05"), props.Text{Size: 7, Left: 2, Top: 1})),
|
|
col.New(2).Add(text.New(s.Protocol, props.Text{Size: 7, Style: fontstyle.Bold, Left: 2, Top: 1})),
|
|
col.New(4).Add(text.New(s.SrcIP, props.Text{Size: 7, Left: 2, Top: 1})),
|
|
col.New(3).Add(text.New(s.DstIP+" "+humanBytes(s.Bytes), props.Text{Size: 7, Left: 2, Top: 1})),
|
|
)
|
|
r.WithStyle(dataRowStyle(idx))
|
|
return []core.Row{r}
|
|
}
|
|
|
|
func buildAlertsTableHeader() []core.Row {
|
|
return []core.Row{tableHeaderRow([]colDef{
|
|
{w: 3, label: "Дата/Время"},
|
|
{w: 2, label: "Тип"},
|
|
{w: 2, label: "Критичность"},
|
|
{w: 3, label: "Источник"},
|
|
{w: 2, label: "Описание"},
|
|
})}
|
|
}
|
|
|
|
func buildAlertRow(a storage.Alert, idx int) []core.Row {
|
|
r := row.New(7).Add(
|
|
col.New(3).Add(text.New(a.CreatedAt.Format("02.01.06 15:04"), props.Text{Size: 7, Left: 2, Top: 2})),
|
|
col.New(2).Add(text.New(typeLabel(a.Type), props.Text{Size: 7, Left: 2, Top: 2})),
|
|
col.New(2).Add(text.New(severityLabel(a.Severity), props.Text{
|
|
Size: 7,
|
|
Style: fontstyle.Bold,
|
|
Color: severityColor(a.Severity),
|
|
Left: 2,
|
|
Top: 2,
|
|
})),
|
|
col.New(3).Add(text.New(a.SrcIP, props.Text{Size: 7, Left: 2, Top: 2})),
|
|
col.New(2).Add(text.New(truncate(a.Description, 40), props.Text{Size: 7, Left: 2, Top: 2})),
|
|
)
|
|
r.WithStyle(dataRowStyle(idx))
|
|
return []core.Row{r}
|
|
}
|
|
|
|
// ── Вспомогательные типы и функции ───────────────────────────────────────────
|
|
|
|
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: colorWhite,
|
|
Left: 2,
|
|
Top: 2,
|
|
}),
|
|
))
|
|
}
|
|
r := row.New(8).Add(cells...)
|
|
r.WithStyle(&props.Cell{
|
|
BackgroundColor: colorBlue,
|
|
BorderColor: colorBlueDark,
|
|
BorderType: border.Full,
|
|
BorderThickness: 0.3,
|
|
})
|
|
return r
|
|
}
|
|
|
|
func dataRowStyle(idx int) *props.Cell {
|
|
bg := colorWhite
|
|
if idx%2 == 0 {
|
|
bg = colorGray100
|
|
}
|
|
return &props.Cell{
|
|
BackgroundColor: bg,
|
|
BorderColor: colorGray200,
|
|
BorderType: border.Bottom,
|
|
BorderThickness: 0.3,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
runes := []rune(s)
|
|
if len(runes) <= n {
|
|
return s
|
|
}
|
|
return string(runes[:n-3]) + "..."
|
|
}
|