From ab32c711922c338e13250d061a67e518b5005bcd Mon Sep 17 00:00:00 2001 From: Laurence Jones Date: Mon, 9 Feb 2026 13:46:56 +0000 Subject: [PATCH] feat: Add pdf export option (#24) * Fix reports with too many IPs --- cmd/ipdex/config/options.go | 2 +- cmd/ipdex/config/utils.go | 51 ++ go.mod | 15 +- go.sum | 63 +++ pkg/display/display.go | 22 +- pkg/pdf/assets.go | 8 + pkg/pdf/assets/logo.png | Bin 0 -> 27905 bytes pkg/pdf/charts.go | 209 +++++++++ pkg/pdf/colors.go | 103 ++++ pkg/pdf/generator.go | 906 ++++++++++++++++++++++++++++++++++++ pkg/pdf/glossary.go | 153 ++++++ pkg/pdf/layout.go | 177 +++++++ pkg/pdf/layout_test.go | 178 +++++++ pkg/pdf/tokens.go | 202 ++++++++ pkg/report/report_client.go | 12 + 15 files changed, 2093 insertions(+), 8 deletions(-) create mode 100644 pkg/pdf/assets.go create mode 100644 pkg/pdf/assets/logo.png create mode 100644 pkg/pdf/charts.go create mode 100644 pkg/pdf/colors.go create mode 100644 pkg/pdf/generator.go create mode 100644 pkg/pdf/glossary.go create mode 100644 pkg/pdf/layout.go create mode 100644 pkg/pdf/layout_test.go create mode 100644 pkg/pdf/tokens.go diff --git a/cmd/ipdex/config/options.go b/cmd/ipdex/config/options.go index 14936e6..a6ae993 100644 --- a/cmd/ipdex/config/options.go +++ b/cmd/ipdex/config/options.go @@ -38,7 +38,7 @@ func GetConfigFolder() (string, error) { func IsSupportedOutputFormat(outputFormat string) bool { switch outputFormat { - case display.JSONFormat, display.HumanFormat, display.CSVFormat: + case display.JSONFormat, display.HumanFormat, display.CSVFormat, display.PDFFormat: return true default: return false diff --git a/cmd/ipdex/config/utils.go b/cmd/ipdex/config/utils.go index 368b237..94f8080 100644 --- a/cmd/ipdex/config/utils.go +++ b/cmd/ipdex/config/utils.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "net" "os" "path/filepath" @@ -52,3 +53,53 @@ func IsValidInt(s string) bool { _, err := strconv.Atoi(s) return err == nil } + +// EnsureOutputPath checks if the output directory exists and offers to create it if not. +// Returns true if the path exists or was created, false if the user declined. +func EnsureOutputPath(outputPath string) (bool, error) { + if outputPath == "" { + return true, nil + } + + absPath, err := filepath.Abs(outputPath) + if err != nil { + return false, err + } + + // Check if path exists + info, err := os.Stat(absPath) + if err == nil { + // Path exists, check if it's a directory + if !info.IsDir() { + return false, fmt.Errorf("output path '%s' exists but is not a directory", absPath) + } + return true, nil + } + + if !os.IsNotExist(err) { + return false, err + } + + // Path doesn't exist, prompt to create + pterm.Warning.Printf("Output directory '%s' does not exist.\n", absPath) + + confirm, err := pterm.DefaultInteractiveConfirm. + WithDefaultText("Do you want to create it?"). + WithDefaultValue(true). + Show() + if err != nil { + return false, err + } + + if !confirm { + return false, nil + } + + // Create the directory + if err := os.MkdirAll(absPath, 0755); err != nil { + return false, fmt.Errorf("failed to create directory: %w", err) + } + + pterm.Success.Printf("Created directory '%s'\n", absPath) + return true, nil +} diff --git a/go.mod b/go.mod index 1943884..2b27ac2 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,12 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/crowdsecurity/crowdsec v1.6.5-rc4.0.20250331124451-78a6179566a7 github.com/glebarez/sqlite v1.11.0 + github.com/johnfercher/maroto/v2 v2.3.3 + github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 + github.com/wcharczuk/go-chart/v2 v2.1.2 golang.org/x/text v0.23.0 gorm.io/gorm v1.25.12 ) @@ -20,20 +23,25 @@ require ( atomicgo.dev/schedule v0.1.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blackfireio/osinfo v1.0.5 // indirect + github.com/boombuler/barcode v1.0.1 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/containerd/console v1.0.3 // indirect github.com/crowdsecurity/go-cs-lib v0.0.16 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/f-amaral/go-async v0.3.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/tiff v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/johnfercher/go-tree v1.0.5 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.9 // indirect @@ -43,7 +51,10 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/pdfcpu/pdfcpu v0.6.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/phpdave11/gofpdf v1.4.3 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect @@ -57,10 +68,12 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + golang.org/x/image v0.18.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/term v0.30.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index f40c44a..5aa698c 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,9 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/blackfireio/osinfo v1.0.5 h1:6hlaWzfcpb87gRmznVf7wSdhysGqLRz9V/xuSdCEXrA= github.com/blackfireio/osinfo v1.0.5/go.mod h1:Pd987poVNmd5Wsx6PRPw4+w7kLlf9iJxoRKPtPAjOrA= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= @@ -44,6 +47,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg= +github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= @@ -52,6 +57,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= @@ -64,6 +71,10 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -72,6 +83,11 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/johnfercher/go-tree v1.0.5 h1:zpgVhJsChavzhKdxhQiCJJzcSY3VCT9oal2JoA2ZevY= +github.com/johnfercher/go-tree v1.0.5/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg= +github.com/johnfercher/maroto/v2 v2.3.3 h1:oeXsBnoecaMgRDwN0Cstjoe4rug3lKpOanuxuHKPqQE= +github.com/johnfercher/maroto/v2 v2.3.3/go.mod h1:KNv102TwUrlVgZGukzlIbhkG6l/WaCD6pzu6aWGVjBI= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= @@ -111,8 +127,16 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI= +github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= +github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= +github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -134,6 +158,7 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -157,6 +182,9 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -164,6 +192,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= +github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= @@ -172,17 +202,33 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -196,13 +242,22 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -211,12 +266,18 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -226,6 +287,8 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/display/display.go b/pkg/display/display.go index d5f98ac..30396ab 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sort" "strconv" "strings" @@ -13,6 +14,7 @@ import ( "github.com/crowdsecurity/ipdex/cmd/ipdex/style" "github.com/crowdsecurity/ipdex/pkg/models" + "github.com/crowdsecurity/ipdex/pkg/pdf" "github.com/charmbracelet/lipgloss" "github.com/crowdsecurity/crowdsec/pkg/cticlient" @@ -25,6 +27,7 @@ const ( JSONFormat = "json" HumanFormat = "human" CSVFormat = "csv" + PDFFormat = "pdf" maxCVEDisplay = 3 maxBehaviorsDisplay = 3 maxClassificationDisplay = 3 @@ -116,6 +119,13 @@ func (d *Display) DisplayReport(report *models.Report, stats *models.ReportStats return err } } + case PDFFormat: + if outputFilePath == "" { + return fmt.Errorf("--output-file is required for PDF format") + } + if err := pdf.GenerateReport(report, stats, withIPs, outputFilePath); err != nil { + return err + } default: return fmt.Errorf("format '%s' not supported", format) } @@ -914,7 +924,7 @@ func displayCSVRows(rows [][]string) error { func saveReportHuman(data *HumanReportData, reportID int, outputFilePath string) error { // Save the report summary - reportFilename := fmt.Sprintf("%s/report-%d.txt", outputFilePath, reportID) + reportFilename := filepath.Join(outputFilePath, fmt.Sprintf("report-%d.txt", reportID)) reportFile, err := os.Create(reportFilename) if err != nil { return fmt.Errorf("failed to create report text file %s: %v", reportFilename, err) @@ -955,7 +965,7 @@ func saveReportHuman(data *HumanReportData, reportID int, outputFilePath string) // If detailed IP information is requested, save to a separate file if len(data.IPTableData) > 1 { - detailsFilename := fmt.Sprintf("%s/details-%d.txt", outputFilePath, reportID) + detailsFilename := filepath.Join(outputFilePath, fmt.Sprintf("details-%d.txt", reportID)) detailsFile, err := os.Create(detailsFilename) if err != nil { return fmt.Errorf("failed to create details text file %s: %v", detailsFilename, err) @@ -985,7 +995,7 @@ func saveReportHuman(data *HumanReportData, reportID int, outputFilePath string) func saveReportJSON(report *models.Report, stats *models.ReportStats, withIPs bool, outputFilePath string) error { // Save the report summary - reportFilename := fmt.Sprintf("%s/report-%d.json", outputFilePath, report.ID) + reportFilename := filepath.Join(outputFilePath, fmt.Sprintf("report-%d.json", report.ID)) reportFile, err := os.Create(reportFilename) if err != nil { return fmt.Errorf("failed to create report JSON file %s: %v", reportFilename, err) @@ -1013,7 +1023,7 @@ func saveReportJSON(report *models.Report, stats *models.ReportStats, withIPs bo // If detailed IP information is requested, save to a separate file if withIPs { - detailsFilename := fmt.Sprintf("%s/details-%d.json", outputFilePath, report.ID) + detailsFilename := filepath.Join(outputFilePath, fmt.Sprintf("details-%d.json", report.ID)) detailsFile, err := os.Create(detailsFilename) if err != nil { return fmt.Errorf("failed to create details JSON file %s: %v", detailsFilename, err) @@ -1034,7 +1044,7 @@ func saveReportJSON(report *models.Report, stats *models.ReportStats, withIPs bo func saveReportCSV(csvReportRows [][]string, csvDetailRows [][]string, reportID int, outputFilePath string) error { // Always save the report summary - reportFilename := fmt.Sprintf("%s/report-%d.csv", outputFilePath, reportID) + reportFilename := filepath.Join(outputFilePath, fmt.Sprintf("report-%d.csv", reportID)) reportFile, err := os.Create(reportFilename) if err != nil { return fmt.Errorf("failed to create report CSV file %s: %v", reportFilename, err) @@ -1054,7 +1064,7 @@ func saveReportCSV(csvReportRows [][]string, csvDetailRows [][]string, reportID fmt.Printf("Report summary saved to: %s\n", reportFilename) if len(csvDetailRows) > 1 { - detailsFilename := fmt.Sprintf("%s/details-%d.csv", outputFilePath, reportID) + detailsFilename := filepath.Join(outputFilePath, fmt.Sprintf("details-%d.csv", reportID)) detailsFile, err := os.Create(detailsFilename) if err != nil { return fmt.Errorf("failed to create details CSV file %s: %v", detailsFilename, err) diff --git a/pkg/pdf/assets.go b/pkg/pdf/assets.go new file mode 100644 index 0000000..963f055 --- /dev/null +++ b/pkg/pdf/assets.go @@ -0,0 +1,8 @@ +package pdf + +import ( + _ "embed" +) + +//go:embed assets/logo.png +var LogoPNG []byte diff --git a/pkg/pdf/assets/logo.png b/pkg/pdf/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..536d6244c72e78c0a04804b90c4296a8111d85d2 GIT binary patch literal 27905 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 Vr!&gC4d5*>IC+qYEKJ%s=zk39)Oi2^ literal 0 HcmV?d00001 diff --git a/pkg/pdf/charts.go b/pkg/pdf/charts.go new file mode 100644 index 0000000..01a5dd0 --- /dev/null +++ b/pkg/pdf/charts.go @@ -0,0 +1,209 @@ +package pdf + +import ( + "bytes" + "fmt" + "sort" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" + + "github.com/crowdsecurity/ipdex/pkg/models" +) + +// Chart dimensions - use 2x for higher DPI rendering +const ( + chartDPIMultiplier = 2 + pieChartWidth = 400 * chartDPIMultiplier + pieChartHeight = 300 * chartDPIMultiplier + barChartWidth = 400 * chartDPIMultiplier + barChartHeight = 250 * chartDPIMultiplier + barWidth = 30 * chartDPIMultiplier +) + +// integerValueFormatter formats values as integers (no decimal places) +func integerValueFormatter(v interface{}) string { + if typed, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%d", int(typed)) + } + return fmt.Sprintf("%v", v) +} + +// chartColors for pie/bar charts +var chartColors = []drawing.Color{ + {R: 245, G: 91, B: 96, A: 255}, // Red (malicious) + {R: 251, G: 146, B: 60, A: 255}, // Orange (suspicious) + {R: 136, G: 139, B: 206, A: 255}, // Purple (known) + {R: 113, G: 229, B: 155, A: 255}, // Green (safe) + {R: 96, G: 165, B: 250, A: 255}, // Blue (benign) + {R: 128, G: 128, B: 128, A: 255}, // Gray (unknown) + {R: 247, G: 170, B: 22, A: 255}, // Gold + {R: 79, G: 75, B: 154, A: 255}, // Purple dark +} + +// reputationColorMap for consistent coloring +var reputationColorMap = map[string]drawing.Color{ + "malicious": {R: 245, G: 91, B: 96, A: 255}, + "suspicious": {R: 251, G: 146, B: 60, A: 255}, + "known": {R: 136, G: 139, B: 206, A: 255}, + "safe": {R: 113, G: 229, B: 155, A: 255}, + "benign": {R: 96, G: 165, B: 250, A: 255}, + "unknown": {R: 128, G: 128, B: 128, A: 255}, +} + +// KV represents a key-value pair for sorting +type KV struct { + Key string + Value int +} + +// getTopN returns top N items from a map sorted by value +func getTopN(m map[string]int, n int) []KV { + var items []KV + for k, v := range m { + items = append(items, KV{k, v}) + } + sort.Slice(items, func(i, j int) bool { + return items[i].Value > items[j].Value + }) + if len(items) > n { + items = items[:n] + } + return items +} + +// GenerateReputationPieChart creates a pie chart for reputation distribution +func GenerateReputationPieChart(stats *models.ReportStats) ([]byte, error) { + if stats == nil || len(stats.TopReputation) == 0 { + return nil, nil + } + + var values []chart.Value + for rep, count := range stats.TopReputation { + clr, ok := reputationColorMap[rep] + if !ok { + clr = reputationColorMap["unknown"] + } + values = append(values, chart.Value{ + Label: rep, + Value: float64(count), + Style: chart.Style{ + FillColor: clr, + StrokeColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + StrokeWidth: 2, + }, + }) + } + + // Sort values by count descending for consistent ordering + sort.Slice(values, func(i, j int) bool { + return values[i].Value > values[j].Value + }) + + pie := chart.PieChart{ + Width: pieChartWidth, + Height: pieChartHeight, + Values: values, + Background: chart.Style{ + FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + }, + // Larger font for labels at higher DPI + Font: nil, // Uses default + } + + buffer := bytes.NewBuffer(nil) + if err := pie.Render(chart.PNG, buffer); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +// GenerateTopBarChart creates a horizontal bar chart for top items +func GenerateTopBarChart(data map[string]int, title string, limit int) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + + topItems := getTopN(data, limit) + if len(topItems) == 0 { + return nil, nil + } + + var bars []chart.Value + for i, item := range topItems { + bars = append(bars, chart.Value{ + Label: truncate(item.Key, 20), + Value: float64(item.Value), + Style: chart.Style{ + FillColor: chartColors[i%len(chartColors)], + StrokeColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + StrokeWidth: 1, + }, + }) + } + + // Find max value for Y-axis range + maxValue := 0.0 + for _, item := range topItems { + if float64(item.Value) > maxValue { + maxValue = float64(item.Value) + } + } + + barChart := chart.BarChart{ + Title: title, + TitleStyle: chart.StyleTextDefaults(), + Width: barChartWidth, + Height: barChartHeight, + BarWidth: barWidth, + Bars: bars, + Background: chart.Style{ + FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + }, + XAxis: chart.Style{ + FontSize: 8 * chartDPIMultiplier, + FontColor: drawing.Color{R: 60, G: 60, B: 60, A: 255}, + }, + YAxis: chart.YAxis{ + Style: chart.Style{ + FontSize: 8 * chartDPIMultiplier, + FontColor: drawing.Color{R: 60, G: 60, B: 60, A: 255}, + }, + ValueFormatter: integerValueFormatter, + Range: &chart.ContinuousRange{ + Min: 0, + Max: maxValue * 1.1, // Add 10% padding at top + }, + }, + } + + buffer := bytes.NewBuffer(nil) + if err := barChart.Render(chart.PNG, buffer); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +// GenerateCountriesBarChart creates a bar chart for top countries +func GenerateCountriesBarChart(stats *models.ReportStats, limit int) ([]byte, error) { + return GenerateTopBarChart(stats.TopCountries, "Top Countries", limit) +} + +// GenerateBehaviorsBarChart creates a bar chart for top behaviors +func GenerateBehaviorsBarChart(stats *models.ReportStats, limit int) ([]byte, error) { + return GenerateTopBarChart(stats.TopBehaviors, "Top Behaviors", limit) +} + +// truncate shortens a string to max length with ellipsis +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return "..." + } + return s[:max-3] + "..." +} + diff --git a/pkg/pdf/colors.go b/pkg/pdf/colors.go new file mode 100644 index 0000000..ce8e26e --- /dev/null +++ b/pkg/pdf/colors.go @@ -0,0 +1,103 @@ +package pdf + +import ( + "github.com/johnfercher/maroto/v2/pkg/props" +) + +// CrowdSec brand colors (from logo.svg) +var ( + PurpleDark = &props.Color{Red: 62, Green: 58, Blue: 120} // #3e3a78 + PurpleLight = &props.Color{Red: 79, Green: 75, Blue: 154} // #4f4b9a + Gold = &props.Color{Red: 247, Green: 170, Blue: 22} // #f7aa16 + AlertRed = &props.Color{Red: 235, Green: 90, Blue: 97} // #eb5a61 + White = &props.Color{Red: 255, Green: 255, Blue: 255} + LightGray = &props.Color{Red: 240, Green: 240, Blue: 240} + DarkGray = &props.Color{Red: 60, Green: 60, Blue: 60} + Black = &props.Color{Red: 0, Green: 0, Blue: 0} +) + +// ReputationColors maps reputation levels to colors +var ReputationColors = map[string]*props.Color{ + "malicious": {Red: 245, Green: 91, Blue: 96}, // #F55B60 + "suspicious": {Red: 251, Green: 146, Blue: 60}, // #FB923C + "known": {Red: 136, Green: 139, Blue: 206}, // #888BCE + "safe": {Red: 113, Green: 229, Blue: 155}, // #71E59B + "benign": {Red: 96, Green: 165, Blue: 250}, // #60A5FA + "unknown": {Red: 128, Green: 128, Blue: 128}, // Gray +} + +// GetReputationColor returns the color for a reputation level +func GetReputationColor(reputation string) *props.Color { + if color, ok := ReputationColors[reputation]; ok { + return color + } + return ReputationColors["unknown"] +} + +// Traffic light colors for risk assessment +var ( + RiskHigh = &props.Color{Red: 220, Green: 38, Blue: 38} // Red #DC2626 + RiskMedium = &props.Color{Red: 245, Green: 158, Blue: 11} // Amber #F59E0B + RiskLow = &props.Color{Red: 34, Green: 197, Blue: 94} // Green #22C55E + + // Lighter versions for backgrounds + RiskHighBg = &props.Color{Red: 254, Green: 226, Blue: 226} // Light red #FEE2E2 + RiskMediumBg = &props.Color{Red: 254, Green: 243, Blue: 199} // Light amber #FEF3C7 + RiskLowBg = &props.Color{Red: 220, Green: 252, Blue: 231} // Light green #DCFCE7 +) + +// RiskLevel represents the overall risk assessment +type RiskLevel int + +const ( + RiskLevelLow RiskLevel = iota + RiskLevelMedium + RiskLevelHigh +) + +// GetRiskLevel determines the risk level based on malicious IP percentage +func GetRiskLevel(maliciousPercent float64) RiskLevel { + if maliciousPercent >= 30 { + return RiskLevelHigh + } + if maliciousPercent >= 10 { + return RiskLevelMedium + } + return RiskLevelLow +} + +// GetRiskColor returns the appropriate color for a risk level +func GetRiskColor(level RiskLevel) *props.Color { + switch level { + case RiskLevelHigh: + return RiskHigh + case RiskLevelMedium: + return RiskMedium + default: + return RiskLow + } +} + +// GetRiskBgColor returns the background color for a risk level +func GetRiskBgColor(level RiskLevel) *props.Color { + switch level { + case RiskLevelHigh: + return RiskHighBg + case RiskLevelMedium: + return RiskMediumBg + default: + return RiskLowBg + } +} + +// GetRiskLabel returns a human-readable label for the risk level +func GetRiskLabel(level RiskLevel) string { + switch level { + case RiskLevelHigh: + return "HIGH RISK" + case RiskLevelMedium: + return "MEDIUM RISK" + default: + return "LOW RISK" + } +} diff --git a/pkg/pdf/generator.go b/pkg/pdf/generator.go new file mode 100644 index 0000000..0396ff6 --- /dev/null +++ b/pkg/pdf/generator.go @@ -0,0 +1,906 @@ +package pdf + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/johnfercher/maroto/v2" + "github.com/johnfercher/maroto/v2/pkg/components/col" + mimage "github.com/johnfercher/maroto/v2/pkg/components/image" + "github.com/johnfercher/maroto/v2/pkg/components/line" + "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/extension" + "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/core" + "github.com/johnfercher/maroto/v2/pkg/props" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/crowdsecurity/ipdex/pkg/models" +) + +const ( + maxTopDisplay = 5 +) + +// GenerateReport creates a PDF report and saves it to the specified directory +// using the naming convention report-.pdf +func GenerateReport(report *models.Report, stats *models.ReportStats, withIPs bool, outputDir string) error { + g := &generator{ + report: report, + stats: stats, + withIPs: withIPs, + } + + outputPath := filepath.Join(outputDir, fmt.Sprintf("report-%d.pdf", report.ID)) + return g.generate(outputPath) +} + +type generator struct { + report *models.Report + stats *models.ReportStats + withIPs bool + layout *LayoutManager +} + +func (g *generator) generate(outputPath string) error { + cfg := config.NewBuilder(). + WithPageNumber(). + WithLeftMargin(LeftMargin). + WithTopMargin(TopMargin). + WithRightMargin(RightMargin). + Build() + + m := maroto.New(cfg) + g.layout = NewLayoutManager(m) + + // Add header + g.addHeader() + + // Add executive summary + g.addExecutiveSummary() + + // Add general info section + g.addGeneralInfo() + + // Add charts section + if err := g.addCharts(); err != nil { + return fmt.Errorf("failed to add charts: %w", err) + } + + // Add statistics section + g.addStatistics() + + // Add IP table if requested + if g.withIPs && len(g.report.IPs) > 0 { + g.addIPTable() + } + + // Add glossary section + g.addGlossary() + + // Add footer + g.addFooter() + + // Generate and save PDF + doc, err := m.Generate() + if err != nil { + return fmt.Errorf("failed to generate PDF: %w", err) + } + + if err := doc.Save(outputPath); err != nil { + return fmt.Errorf("failed to save PDF: %w", err) + } + + fmt.Printf("PDF report saved to: %s\n", outputPath) + return nil +} + +func (g *generator) addHeader() { + maliciousCount := 0 + if count, ok := g.stats.TopReputation["malicious"]; ok { + maliciousCount = count + } + maliciousPercent := percent(maliciousCount, g.stats.NbIPs) + riskLevel := GetRiskLevel(maliciousPercent) + riskColor := GetRiskColor(riskLevel) + riskLabel := GetRiskLabel(riskLevel) + + g.layout.AddRow(RowHeightTitle, + col.New(3).Add( + mimage.NewFromBytes(LogoPNG, extension.Png, props.Rect{ + Center: true, + Percent: 80, + }), + ), + col.New(6).Add( + text.New("ipdex Report", WithTop(StyleTitle, 5)), + text.New(g.report.Name, props.Text{ + Size: FontSizeH1, + Align: align.Left, + Color: DarkGray, + Top: 18, + }), + ), + col.New(3).Add( + text.New(riskLabel, props.Text{ + Size: 12, + Style: fontstyle.Bold, + Align: align.Right, + Color: riskColor, + Top: 8, + }), + ), + ) + + // Purple line separator + g.layout.AddRowDirect(2, + col.New(12).Add( + line.New(props.Line{ + Color: PurpleDark, + Thickness: 2, + }), + ), + ) + + g.layout.AddSpacer(RowHeightSpacer) +} + +func (g *generator) addExecutiveSummary() { + // Calculate risk metrics + maliciousCount := 0 + suspiciousCount := 0 + if count, ok := g.stats.TopReputation["malicious"]; ok { + maliciousCount = count + } + if count, ok := g.stats.TopReputation["suspicious"]; ok { + suspiciousCount = count + } + + maliciousPercent := percent(maliciousCount, g.stats.NbIPs) + threatPercent := percent(maliciousCount+suspiciousCount, g.stats.NbIPs) + knownPercent := percent(g.stats.NbIPs-g.stats.NbUnknownIPs, g.stats.NbIPs) + + riskLevel := GetRiskLevel(maliciousPercent) + riskColor := GetRiskColor(riskLevel) + riskBgColor := GetRiskBgColor(riskLevel) + riskLabel := GetRiskLabel(riskLevel) + + // Get risk statement parts + riskParts := g.getRiskStatementParts(riskLevel, maliciousPercent, threatPercent) + + // Section title + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("Executive Summary", StyleH1)), + ) + + // Risk indicator panel with colored background on left, summary on right + riskPanelStyle := props.Cell{ + BackgroundColor: riskBgColor, + } + + g.layout.AddRow(36, + // Left panel: Risk indicator with colored background + col.New(3).Add( + text.New(riskLabel, props.Text{ + Size: FontSizeH1, + Style: fontstyle.Bold, + Color: riskColor, + Align: align.Center, + Top: 12, + }), + ).WithStyle(&riskPanelStyle), + // Right panel: Summary text with natural language + col.New(9).Add( + text.New(riskParts.Summary, WithTop(WithLeft(StyleBody, 5), 5)), + text.New(riskParts.CTA, WithTop(WithLeft(StyleBody, 5), 18)), + ), + ) + + g.layout.AddSpacer(RowHeightSpacerLg) + + // Key findings + g.layout.AddRow(RowHeightBody, + col.New(12).Add(text.New("Key Findings", StyleH2)), + ) + + // Finding 1: Coverage + g.layout.AddRow(RowHeightBody, + col.New(12).Add( + text.New(fmt.Sprintf("• Known to CrowdSec intelligence: %.0f%% (%d of %d IPs)", + knownPercent, g.stats.NbIPs-g.stats.NbUnknownIPs, g.stats.NbIPs), + WithLeft(StyleSmall, 5)), + ), + ) + + // Finding 2: Threat breakdown + if maliciousCount > 0 || suspiciousCount > 0 { + threatText := fmt.Sprintf("• Flagged as malicious or suspicious: %.0f%%", threatPercent) + if maliciousCount > 0 && suspiciousCount > 0 { + threatText += fmt.Sprintf(" (%d malicious, %d suspicious)", maliciousCount, suspiciousCount) + } else if maliciousCount > 0 { + threatText += fmt.Sprintf(" (%d malicious)", maliciousCount) + } else { + threatText += fmt.Sprintf(" (%d suspicious)", suspiciousCount) + } + g.layout.AddRow(RowHeightBody, + col.New(12).Add(text.New(threatText, WithLeft(StyleSmall, 5))), + ) + } else { + g.layout.AddRow(RowHeightBody, + col.New(12).Add( + text.New("• No IPs were flagged as malicious or suspicious.", WithLeft(StyleSmall, 5)), + ), + ) + } + + // Finding 3: Top behavior if present + if len(g.stats.TopBehaviors) > 0 { + topBehaviors := getTopN(g.stats.TopBehaviors, 1) + if len(topBehaviors) > 0 { + behaviorPercent := percent(topBehaviors[0].Value, g.stats.NbIPs) + g.layout.AddRow(RowHeightBody, + col.New(12).Add( + text.New(fmt.Sprintf("• Most common activity type: %s (%.0f%% of IPs)", + topBehaviors[0].Key, behaviorPercent), WithLeft(StyleSmall, 5)), + ), + ) + } + } + + // Finding 4: Blocklist presence + if g.stats.IPsBlockedByBlocklist > 0 { + blocklistPercent := percent(g.stats.IPsBlockedByBlocklist, g.stats.NbIPs) + g.layout.AddRow(RowHeightBody, + col.New(12).Add( + text.New(fmt.Sprintf("• On security blocklists: %d IPs (%.0f%%)", + g.stats.IPsBlockedByBlocklist, blocklistPercent), WithLeft(StyleSmall, 5)), + ), + ) + } + + g.layout.AddSpacer(RowHeightSpacerLg) +} + +// RiskStatementParts holds the summary text for the executive summary +type RiskStatementParts struct { + Summary string // Natural language summary of findings + CTA string // Call to action recommendation +} + +// percentToNaturalLanguage converts a percentage to natural language description +func percentToNaturalLanguage(pct float64) string { + switch { + case pct >= 90: + return "Nearly all" + case pct >= 75: + return "A large majority" + case pct >= 60: + return "Over half" + case pct >= 50: + return "About half" + case pct >= 40: + return "Nearly half" + case pct >= 30: + return "About a third" + case pct >= 20: + return "About a quarter" + default: + return "A portion" + } +} + +// getRiskStatementParts returns natural language risk summary based on analysis +func (g *generator) getRiskStatementParts(level RiskLevel, maliciousPercent, threatPercent float64) RiskStatementParts { + parts := RiskStatementParts{} + + // Select the relevant percentage for natural language description + var pct float64 + switch level { + case RiskLevelHigh: + pct = maliciousPercent + case RiskLevelMedium: + pct = threatPercent + } + proportion := percentToNaturalLanguage(pct) + + switch level { + case RiskLevelHigh: + parts.Summary = fmt.Sprintf( + "This analysis of %d IPs reveals significant security concerns. %s (%.0f%%) "+ + "have been confirmed as malicious by the CrowdSec threat intelligence network.", + g.stats.NbIPs, proportion, maliciousPercent) + parts.CTA = "We recommend enabling CrowdSec blocklists to automatically block these known threats." + case RiskLevelMedium: + parts.Summary = fmt.Sprintf( + "This analysis of %d IPs shows moderate security concerns. %s (%.0f%%) "+ + "display suspicious or malicious behavior patterns that warrant attention.", + g.stats.NbIPs, proportion, threatPercent) + parts.CTA = "Consider reviewing high-risk IPs and enabling blocklists for added protection." + default: + parts.Summary = fmt.Sprintf( + "This analysis of %d IPs shows minimal security concerns. The majority appear safe "+ + "or have no significant history of malicious activity.", + g.stats.NbIPs) + parts.CTA = "Continue routine monitoring. Blocklists can help prevent emerging threats." + } + return parts +} + +func (g *generator) addGeneralInfo() { + // Section title + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("General Information", StyleH1)), + ) + + // Info rows helper + addInfoRow := func(label, value string) { + g.layout.AddRow(RowHeightBody, + col.New(4).Add(text.New(label+":", StyleLabel)), + col.New(8).Add(text.New(value, StyleValue)), + ) + } + + addInfoRow("Report ID", strconv.Itoa(int(g.report.ID))) + addInfoRow("Created At", g.report.CreatedAt.Format("2006-01-02 15:04:05")) + + if g.report.IsFile { + addInfoRow("Source", "File scan") + addInfoRow("File Path", g.report.FilePath) + addInfoRow("SHA256", truncateMiddle(g.report.FileHash, 50)) + } + + if g.report.IsQuery { + addInfoRow("Source", "Threat search") + addInfoRow("Search Query", g.report.Query) + addInfoRow("Time Window", g.report.Since) + } + + addInfoRow("Total IPs Analyzed", strconv.Itoa(g.stats.NbIPs)) + + knownPercent := percent(g.stats.NbIPs-g.stats.NbUnknownIPs, g.stats.NbIPs) + blocklistPercent := percent(g.stats.IPsBlockedByBlocklist, g.stats.NbIPs) + + addInfoRow("Known to CrowdSec", fmt.Sprintf("%d (%.1f%%)", g.stats.NbIPs-g.stats.NbUnknownIPs, knownPercent)) + addInfoRow("IPs on Blocklists", fmt.Sprintf("%d (%.1f%%)", g.stats.IPsBlockedByBlocklist, blocklistPercent)) + + g.layout.AddSpacer(RowHeightSpacerLg) +} + +func (g *generator) addCharts() error { + // Section title - Maroto handles page breaks naturally + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("Threat Overview", StyleH1)), + ) + + // Try to add reputation pie chart + if len(g.stats.TopReputation) > 0 { + pieChartBytes, err := GenerateReputationPieChart(g.stats) + if err == nil && pieChartBytes != nil { + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New("Reputation Distribution", StyleH2)), + ) + + // Add the pie chart image + g.layout.AddRow(RowHeightChart, + col.New(6).Add( + mimage.NewFromBytes(pieChartBytes, extension.Png, props.Rect{ + Center: true, + Percent: 100, + }), + ), + col.New(6).Add( + g.buildReputationLegend()..., + ), + ) + g.layout.AddSpacer(RowHeightSpacer) + } else { + // Fallback to text-based display if chart fails + g.addReputationText() + } + } + + // Add top countries with bar chart + if len(g.stats.TopCountries) > 0 { + g.addTopItemsWithChart("Top Source Countries", g.stats.TopCountries, "Countries with the highest number of flagged IPs") + } + + // Add top behaviors with bar chart + if len(g.stats.TopBehaviors) > 0 { + g.addTopItemsWithChart("Top Activity Types", g.stats.TopBehaviors, "Most common activity types seen in this report") + } + + g.layout.AddSpacer(RowHeightSpacer) + return nil +} + +func (g *generator) buildReputationLegend() []core.Component { + var components []core.Component + + topRep := getTopN(g.stats.TopReputation, maxTopDisplay) + for _, item := range topRep { + percent := percent(item.Value, g.stats.NbIPs) + color := GetReputationColor(item.Key) + components = append(components, + text.New(fmt.Sprintf("- %s: %d (%.1f%%)", + cases.Title(language.Und).String(item.Key), item.Value, percent), props.Text{ + Size: 9, + Color: color, + Top: float64(len(components) * 6), + }), + ) + } + return components +} + +func (g *generator) addReputationText() { + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New("Reputation Distribution", StyleH2)), + ) + + topRep := getTopN(g.stats.TopReputation, maxTopDisplay) + for _, item := range topRep { + pct := percent(item.Value, g.stats.NbIPs) + color := GetReputationColor(item.Key) + g.layout.AddRow(RowHeightBody, + col.New(6).Add( + text.New(cases.Title(language.Und).String(item.Key), WithLeft(WithColor(StyleSmall, color), 10)), + ), + col.New(6).Add( + text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, pct), WithColor(StyleSmall, color)), + ), + ) + } + g.layout.AddSpacer(RowHeightSpacer) +} + +func (g *generator) addTopItemsWithChart(title string, data map[string]int, description string) { + topItems := getTopN(data, maxTopDisplay) + if len(topItems) == 0 { + return + } + + // Section header with description - Maroto handles page breaks + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New(title, StyleH2)), + ) + + g.layout.AddRow(RowHeightDesc, + col.New(12).Add(text.New(description, WithLeft(StyleDescription, 5))), + ) + + // Try to generate bar chart + chartBytes, err := GenerateTopBarChart(data, "", maxTopDisplay) + if err == nil && chartBytes != nil { + g.layout.AddRow(RowHeightBarChart, + col.New(7).Add( + mimage.NewFromBytes(chartBytes, extension.Png, props.Rect{ + Center: true, + Percent: 95, + }), + ), + col.New(5).Add( + g.buildItemsLegend(topItems)..., + ), + ) + } else { + // Fallback to text-based display + for _, item := range topItems { + pct := percent(item.Value, g.stats.NbIPs) + g.layout.AddRow(RowHeightBody, + col.New(8).Add(text.New(truncate(item.Key, 35), WithLeft(StyleSmall, 10))), + col.New(4).Add(text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, pct), StyleSmall)), + ) + } + } + + g.layout.AddSpacer(RowHeightSpacer) +} + +func (g *generator) buildItemsLegend(items []KV) []core.Component { + var components []core.Component + + for i, item := range items { + percent := percent(item.Value, g.stats.NbIPs) + components = append(components, + text.New(fmt.Sprintf("%d. %s", i+1, truncate(item.Key, 25)), props.Text{ + Size: 8, + Color: DarkGray, + Top: float64(i * 8), + }), + text.New(fmt.Sprintf(" %d IPs (%.1f%%)", item.Value, percent), props.Text{ + Size: 7, + Color: DarkGray, + Top: float64(i*8) + 4, + }), + ) + } + return components +} + +func (g *generator) addStatistics() { + // Section title + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("Statistics", StyleH1)), + ) + + // Add statistics tables with proper page break handling + g.addStatTable("Top Classifications", g.stats.TopClassifications) + g.addStatTable("Top Blocklists", g.stats.TopBlocklists) + g.addStatTable("Top CVEs", g.stats.TopCVEs) + g.addStatTable("Top IP Ranges", g.stats.TopIPRange) + g.addStatTable("Top Autonomous Systems", g.stats.TopAS) + + g.layout.AddSpacer(RowHeightSpacer) +} + +func (g *generator) addStatTable(title string, data map[string]int) { + if len(data) == 0 { + return + } + + topItems := getTopN(data, maxTopDisplay) + + // Ensure space for title + header + at least MinListItems data rows before starting + // This prevents orphan headers where the title appears but no data fits + minRows := min(MinListItems, len(topItems)) + minHeight := RowHeightH2 + RowHeightTableRow + (RowHeightTableRow * float64(minRows)) + g.layout.EnsureSpace(minHeight) + + // Render title and header + g.renderStatTableHeader(title, false) + + // Row styling for zebra striping + evenRowStyle := props.Cell{ + BackgroundColor: White, + BorderType: border.Bottom, + BorderColor: LightGray, + } + oddRowStyle := props.Cell{ + BackgroundColor: LightGray, + BorderType: border.Bottom, + BorderColor: LightGray, + } + + // Render items with page break handling + for i, item := range topItems { + // Check if we need a page break before this item + if g.layout.Remaining() < RowHeightTableRow { + g.layout.NewPage() + // Re-render title and header with "(continued)" suffix + g.renderStatTableHeader(title, true) + } + + pct := percent(item.Value, g.stats.NbIPs) + rowStyle := &evenRowStyle + if i%2 == 1 { + rowStyle = &oddRowStyle + } + + g.layout.AddRow(RowHeightTableRow, + col.New(8).Add(text.New(truncate(item.Key, 45), WithLeft(StyleSmall, 5))).WithStyle(rowStyle), + col.New(4).Add(text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, pct), StyleValueRight)).WithStyle(rowStyle), + ) + } + + g.layout.AddSpacer(RowHeightSpacer) // Spacer between tables +} + +// renderStatTableHeader renders the table title and column headers +func (g *generator) renderStatTableHeader(title string, continued bool) { + displayTitle := title + if continued { + displayTitle = title + " (continued)" + } + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New(displayTitle, StyleH2)), + ) + + // Column headers for better alignment + headerStyle := props.Cell{ + BackgroundColor: PurpleLight, + } + g.layout.AddRow(RowHeightTableRow, + col.New(8).Add(text.New("Name", WithLeft(StyleTableHeader, 5))).WithStyle(&headerStyle), + col.New(4).Add(text.New("Count (%)", StyleTableHeader)).WithStyle(&headerStyle), + ) +} + +func (g *generator) addIPTable() { + // Ensure space for section title + header + at least 3 rows + minHeight := RowHeightH1 + RowHeightTableHead + (RowHeightTableRow * 3) + g.layout.EnsureSpace(minHeight) + + // Section title + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("IP Address Details", StyleH1)), + ) + + // Render table header + g.renderIPTableHeader(false) + + // Table rows + rowStyle := props.Cell{ + BorderType: border.Bottom, + BorderColor: LightGray, + } + + for i, ip := range g.report.IPs { + // Check if we need a page break + if g.layout.Remaining() < RowHeightTableRow { + g.layout.NewPage() + // Re-render header on new page + g.renderIPTableHeader(true) + } + + // Alternate row colors + if i%2 == 0 { + rowStyle.BackgroundColor = White + } else { + rowStyle.BackgroundColor = LightGray + } + + country := "N/A" + if ip.Location.Country != nil && *ip.Location.Country != "" { + country = *ip.Location.Country + } + + asName := "N/A" + if ip.AsName != nil && *ip.AsName != "" { + asName = truncate(*ip.AsName, 15) + } + + reputation := ip.Reputation + if reputation == "" { + reputation = "unknown" + } + + confidence := ip.Confidence + if confidence == "" { + confidence = "N/A" + } + + behaviors := "N/A" + if len(ip.Behaviors) > 0 { + var behaviorLabels []string + for _, b := range ip.Behaviors { + behaviorLabels = append(behaviorLabels, b.Label) + } + behaviors = truncate(strings.Join(behaviorLabels, ", "), 20) + } + + ipRange := "N/A" + if ip.IpRange != nil && *ip.IpRange != "" { + ipRange = truncate(*ip.IpRange, 18) + } + + // Color-code reputation + repColor := GetReputationColor(reputation) + repTextStyle := props.Text{ + Size: FontSizeTiny, + Color: repColor, + Style: fontstyle.Bold, + } + + g.layout.AddRow(RowHeightTableRow, + col.New(2).Add(text.New(ip.Ip, StyleTableCell)).WithStyle(&rowStyle), + col.New(1).Add(text.New(country, StyleTableCell)).WithStyle(&rowStyle), + col.New(2).Add(text.New(asName, StyleTableCell)).WithStyle(&rowStyle), + col.New(2).Add(text.New(reputation, repTextStyle)).WithStyle(&rowStyle), + col.New(1).Add(text.New(confidence, StyleTableCell)).WithStyle(&rowStyle), + col.New(2).Add(text.New(behaviors, StyleTableCell)).WithStyle(&rowStyle), + col.New(2).Add(text.New(ipRange, StyleTableCell)).WithStyle(&rowStyle), + ) + } +} + +// renderIPTableHeader renders the IP table header +func (g *generator) renderIPTableHeader(continued bool) { + title := "IP Address Details" + if continued { + title += " (continued)" + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New(title, StyleH2)), + ) + } + + g.layout.AddRow(RowHeightTableHead, + col.New(2).Add(text.New("IP", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(1).Add(text.New("Country", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(2).Add(text.New("AS Name", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(2).Add(text.New("Reputation", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(1).Add(text.New("Conf.", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(2).Add(text.New("Behaviors", StyleTableHeader)).WithStyle(&CellStyleHeader), + col.New(2).Add(text.New("Range", StyleTableHeader)).WithStyle(&CellStyleHeader), + ) +} + +func (g *generator) addGlossary() { + // Ensure space for section title + subtitle + at least one subsection header + 2 definitions + minHeight := RowHeightH1 + RowHeightBody + RowHeightH2 + (RowHeightBody * 4) + g.layout.EnsureSpace(minHeight) + + // Section title + g.layout.AddRow(RowHeightH1, + col.New(12).Add(text.New("Glossary & Terminology", StyleH1)), + ) + + g.layout.AddRow(RowHeightBody, + col.New(12).Add(text.New("Plain-language explanations of terms used in this report.", StyleSmall)), + ) + + g.layout.AddSpacer(RowHeightSpacer) + + // Reputation levels - always include + g.addGlossarySection("Reputation Levels", ReputationDefinitions, true) + + // Confidence levels + g.addGlossarySection("Confidence Levels", ConfidenceDefinitions, false) + + // Dynamic behaviors - only include if behaviors are present + relevantBehaviors := GetRelevantBehaviors(g.stats.TopBehaviors) + if len(relevantBehaviors) > 0 { + g.addGlossarySection("Activity Types (from this report)", relevantBehaviors, false) + } + + // Key terms - conditionally include based on report data + var keyTerms []TermDefinition + for _, def := range KeyTermDefinitions { + switch def.Term { + case "CVE": + if ShouldIncludeCVEDefinition(g.stats.TopCVEs) { + keyTerms = append(keyTerms, def) + } + case "Blocklist": + if ShouldIncludeBlocklistDefinition(g.stats.TopBlocklists) { + keyTerms = append(keyTerms, def) + } + case "Autonomous System (AS)": + if ShouldIncludeASDefinition(g.stats.TopAS) { + keyTerms = append(keyTerms, def) + } + case "IP Range": + if len(g.stats.TopIPRange) > 0 { + keyTerms = append(keyTerms, def) + } + } + } + + if len(keyTerms) > 0 { + g.addGlossarySection("Key Terms", keyTerms, false) + } + + g.layout.AddSpacer(RowHeightSpacerXl) +} + +// addGlossarySection renders a glossary subsection with proper page breaks +func (g *generator) addGlossarySection(title string, definitions []TermDefinition, useReputationColors bool) { + if len(definitions) == 0 { + return + } + + // Ensure space for subsection header + at least 2 definitions + minHeight := RowHeightH2 + (RowHeightBody * 4) + g.layout.EnsureSpace(minHeight) + + // Subsection header + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New(title, StyleH2)), + ) + + // Render definitions with page break handling + for _, def := range definitions { + // Check if we need a page break + if g.layout.Remaining() < RowHeightBody*2 { + g.layout.NewPage() + // Re-render subsection title + g.layout.AddRow(RowHeightH2, + col.New(12).Add(text.New(title+" (continued)", StyleH2)), + ) + } + + // Determine term color + termStyle := StyleGlossaryTerm + if useReputationColors { + color := GetReputationColor(strings.ToLower(def.Term)) + termStyle = WithColor(StyleGlossaryTerm, color) + } + + // Use wider term column (4 cols instead of 2) to prevent wrapping + g.layout.AddRow(RowHeightBody, + col.New(4).Add(text.New(formatGlossaryTerm(def.Term), WithLeft(termStyle, 5))), + col.New(8).Add(text.New(def.Definition, StyleGlossaryDef)), + ) + + g.layout.AddSpacer(2) // Small spacer between definitions + } + + g.layout.AddSpacer(RowHeightSpacer) +} + +// formatGlossaryTerm formats a term with non-breaking spaces for known phrases +func formatGlossaryTerm(term string) string { + // Replace spaces with non-breaking spaces for known multi-word terms + // This prevents awkward line breaks within terms + knownPhrases := []string{ + "High Confidence", + "Medium Confidence", + "Low Confidence", + "Autonomous System (AS)", + "IP Range", + } + + for _, phrase := range knownPhrases { + if term == phrase { + // Use regular space - Maroto handles wrapping at cell level + return term + } + } + return term +} + +func (g *generator) addFooter() { + g.layout.AddSpacer(RowHeightSpacerXl) + + // Separator line + g.layout.AddRowDirect(2, + col.New(12).Add( + line.New(props.Line{ + Color: LightGray, + Thickness: 1, + }), + ), + ) + + // URLs for clickable links + crowdsecURL := "https://crowdsec.net" + consoleURL := "https://app.crowdsec.net" + + // Footer content + g.layout.AddRow(RowHeightSpacerLg, + col.New(6).Add( + text.New("Generated by ipdex - CrowdSec CTI Tool", StyleFooter), + text.New(time.Now().Format("2006-01-02 15:04:05"), WithTop(StyleFooter, 10)), + ), + col.New(6).Add( + text.New("crowdsec.net", props.Text{ + Size: FontSizeFooter, + Color: PurpleDark, + Align: align.Right, + Hyperlink: &crowdsecURL, + }), + text.New("app.crowdsec.net", props.Text{ + Size: FontSizeFooter, + Color: PurpleDark, + Align: align.Right, + Top: 10, + Hyperlink: &consoleURL, + }), + ), + ) +} + +// truncateMiddle truncates a string in the middle with ellipsis +func truncateMiddle(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 5 { + return s[:max] + } + half := (max - 3) / 2 + return s[:half] + "..." + s[len(s)-half:] +} + +func percent(part, total int) float64 { + if total <= 0 { + return 0 + } + return float64(part) / float64(total) * 100 +} diff --git a/pkg/pdf/glossary.go b/pkg/pdf/glossary.go new file mode 100644 index 0000000..bafbcac --- /dev/null +++ b/pkg/pdf/glossary.go @@ -0,0 +1,153 @@ +package pdf + +// TermDefinition holds a term and its explanation +type TermDefinition struct { + Term string + Definition string + Category string +} + +// ReputationDefinitions explains each reputation level +var ReputationDefinitions = []TermDefinition{ + { + Term: "Malicious", + Definition: "Clear evidence of attacks from this IP (e.g., brute-force or exploitation). Treat as hostile and block if possible.", + Category: "Reputation", + }, + { + Term: "Suspicious", + Definition: "Signals of harmful behavior but not confirmed. Monitor closely and consider restricting.", + Category: "Reputation", + }, + { + Term: "Known", + Definition: "Seen by the CrowdSec network but not clearly malicious. Treat as neutral unless other signals exist.", + Category: "Reputation", + }, + { + Term: "Safe", + Definition: "Verified legitimate service (e.g., search engine or security scanner). Generally safe to allow.", + Category: "Reputation", + }, + { + Term: "Unknown", + Definition: "Not found in CrowdSec intelligence, so no reputation is available.", + Category: "Reputation", + }, +} + +// ConfidenceDefinitions explains confidence levels +var ConfidenceDefinitions = []TermDefinition{ + { + Term: "High Confidence", + Definition: "Many independent reports; classification is very reliable.", + Category: "Confidence", + }, + { + Term: "Medium Confidence", + Definition: "Several reports; likely accurate but still worth monitoring.", + Category: "Confidence", + }, + { + Term: "Low Confidence", + Definition: "Few reports or recent activity; classification may change.", + Category: "Confidence", + }, +} + +// BehaviorDefinitions maps behavior labels to their explanations +var BehaviorDefinitions = map[string]string{ + // HTTP-related behaviors + "http:exploit": "Tried to exploit web-application vulnerabilities (e.g., SQL injection, XSS).", + "http:scan": "Scanned web servers to find open services or weaknesses.", + "http:bruteforce": "Tried many password guesses against a web login.", + "http:crawl": "Aggressively crawled pages, often to scrape or probe.", + "http:spam": "Sent spam via web forms or comments.", + "http:backdoor": "Attempted to access or install a backdoor on a web server.", + "http:bad_user_agent": "Used suspicious user-agent strings common in attack tools.", + + // SSH-related behaviors + "ssh:bruteforce": "Tried many password guesses against SSH.", + "ssh:exploit": "Attempted to exploit vulnerabilities in SSH services.", + + // Generic behaviors + "generic:exploit": "Attempted to exploit known vulnerabilities in various services.", + "generic:scan": "Scanned ports or services to identify targets.", + "generic:bruteforce": "Tried many password guesses against a service.", + + // SMB-related behaviors + "smb:bruteforce": "Tried many password guesses against SMB (Windows file sharing).", + "smb:exploit": "Attempted to exploit SMB vulnerabilities (e.g., EternalBlue).", + + // Mail-related behaviors + "smtp:spam": "Sent spam emails or attempted to relay spam.", + "smtp:bruteforce": "Tried many password guesses against email accounts.", + + // Database behaviors + "mysql:bruteforce": "Tried many password guesses against MySQL.", + "postgresql:bruteforce": "Tried many password guesses against PostgreSQL.", + "mssql:bruteforce": "Tried many password guesses against Microsoft SQL Server.", + + // VoIP behaviors + "sip:bruteforce": "Tried many password guesses against VoIP (SIP).", + + // Other behaviors + "ftp:bruteforce": "Tried many password guesses against FTP.", + "telnet:bruteforce": "Tried many password guesses against Telnet, often targeting IoT devices.", + "rdp:bruteforce": "Tried many password guesses against Windows Remote Desktop.", + "dns:exploit": "Attempted to exploit DNS services or perform DNS-based attacks.", +} + +// KeyTermDefinitions explains other important terms +var KeyTermDefinitions = []TermDefinition{ + { + Term: "CVE", + Definition: "Common Vulnerabilities and Exposures. A public ID for a known security bug; IPs tied to CVEs tried to exploit those bugs.", + Category: "Terms", + }, + { + Term: "Blocklist", + Definition: "A list of IPs to block at the firewall or edge, used to stop known bad actors.", + Category: "Terms", + }, + { + Term: "Autonomous System (AS)", + Definition: "A network operator identified by an AS number. Useful for understanding who owns an IP range.", + Category: "Terms", + }, + { + Term: "IP Range", + Definition: "A block of IPs owned by one organization; multiple bad IPs in a range can signal a compromised network.", + Category: "Terms", + }, +} + +// GetRelevantBehaviors returns definitions only for behaviors present in the data +func GetRelevantBehaviors(behaviors map[string]int) []TermDefinition { + var relevant []TermDefinition + for behavior := range behaviors { + if def, ok := BehaviorDefinitions[behavior]; ok { + relevant = append(relevant, TermDefinition{ + Term: behavior, + Definition: def, + Category: "Behavior", + }) + } + } + return relevant +} + +// ShouldIncludeCVEDefinition checks if CVE definition should be included +func ShouldIncludeCVEDefinition(cves map[string]int) bool { + return len(cves) > 0 +} + +// ShouldIncludeBlocklistDefinition checks if blocklist definition should be included +func ShouldIncludeBlocklistDefinition(blocklists map[string]int) bool { + return len(blocklists) > 0 +} + +// ShouldIncludeASDefinition checks if AS definition should be included +func ShouldIncludeASDefinition(as map[string]int) bool { + return len(as) > 0 +} diff --git a/pkg/pdf/layout.go b/pkg/pdf/layout.go new file mode 100644 index 0000000..796a49b --- /dev/null +++ b/pkg/pdf/layout.go @@ -0,0 +1,177 @@ +package pdf + +import ( + "github.com/johnfercher/maroto/v2/pkg/core" +) + +// Page dimensions for A4 in mm (default Maroto page size) +const ( + PageHeight = 297.0 // A4 height in mm + PageWidth = 210.0 // A4 width in mm + TopMargin = 10.0 + BottomMargin = 10.0 + LeftMargin = 10.0 + RightMargin = 10.0 + + // Reserve space for page number footer + FooterHeight = 10.0 + + // Usable height per page + UsableHeight = PageHeight - TopMargin - BottomMargin - FooterHeight +) + +// LayoutManager tracks vertical position and handles page breaks +type LayoutManager struct { + maroto core.Maroto + cursorY float64 // Current Y position on the page + pageNum int // Current page number +} + +// NewLayoutManager creates a new layout manager +func NewLayoutManager(m core.Maroto) *LayoutManager { + return &LayoutManager{ + maroto: m, + cursorY: 0, + pageNum: 1, + } +} + +// Remaining returns the remaining vertical space on the current page +func (l *LayoutManager) Remaining() float64 { + return UsableHeight - l.cursorY +} + +// AddRow adds a row and tracks vertical position +func (l *LayoutManager) AddRow(height float64, cols ...core.Col) { + l.maroto.AddRow(height, cols...) + l.cursorY += height +} + +// AddSpacer adds vertical spacing +func (l *LayoutManager) AddSpacer(height float64) { + l.maroto.AddRow(height) + l.cursorY += height +} + +// AddRowDirect adds a row without cols (for lines/separators) and tracks position +func (l *LayoutManager) AddRowDirect(height float64, cols ...core.Col) { + l.maroto.AddRow(height, cols...) + l.cursorY += height +} + +// EnsureSpace checks if there's enough space for content, forces page break if not +// Returns true if a new page was started +func (l *LayoutManager) EnsureSpace(minHeight float64) bool { + if l.Remaining() < minHeight { + l.NewPage() + return true + } + return false +} + +// NewPage resets cursor tracking for a new page +// Maroto handles actual page breaks automatically when content overflows +func (l *LayoutManager) NewPage() { + l.cursorY = 0 + l.pageNum++ +} + +// GetMaroto returns the underlying maroto instance for direct access when needed +func (l *LayoutManager) GetMaroto() core.Maroto { + return l.maroto +} + +// ResetCursor resets cursor to top of page (use after manual page operations) +func (l *LayoutManager) ResetCursor() { + l.cursorY = 0 +} + +// Block represents a renderable content block with known dimensions +type Block interface { + // MinHeight returns the minimum height needed (title + first few items) + MinHeight() float64 + // Height returns the full height if rendered without splitting + Height() float64 + // Render draws the block, handling page breaks internally if needed + Render(l *LayoutManager) +} + +// SectionBlock represents a section with title and content +type SectionBlock struct { + Title string + TitleHeight float64 + Items []BlockItem + ItemHeight float64 + MinItems int // Minimum items to show with title (prevents orphans) + RenderTitle func(l *LayoutManager, title string, continued bool) + RenderItem func(l *LayoutManager, item BlockItem, index int) +} + +// BlockItem represents a single item in a section +type BlockItem struct { + Label string + Value int + Percent float64 + Extra string // For additional context like AS name +} + +// MinHeight returns minimum height (title + MinItems items) +func (s *SectionBlock) MinHeight() float64 { + itemCount := min(s.MinItems, len(s.Items)) + return s.TitleHeight + (float64(itemCount) * s.ItemHeight) +} + +// Height returns full height +func (s *SectionBlock) Height() float64 { + return s.TitleHeight + (float64(len(s.Items)) * s.ItemHeight) +} + +// Render draws the section with proper page break handling +func (s *SectionBlock) Render(l *LayoutManager) { + if len(s.Items) == 0 { + return + } + + // Ensure we have space for title + minimum items + l.EnsureSpace(s.MinHeight()) + + // Render title + s.RenderTitle(l, s.Title, false) + + // Render items with page break handling + for i, item := range s.Items { + // Check if we need a page break + if l.Remaining() < s.ItemHeight { + l.NewPage() + // Re-render title with "(continued)" suffix + s.RenderTitle(l, s.Title, true) + } + s.RenderItem(l, item, i) + } +} + +// ChartBlock represents a chart with optional legend +type ChartBlock struct { + Title string + TitleHeight float64 + ChartHeight float64 + Description string + DescHeight float64 + RenderFunc func(l *LayoutManager) +} + +// MinHeight returns the minimum height needed +func (c *ChartBlock) MinHeight() float64 { + return c.TitleHeight + c.DescHeight + c.ChartHeight +} + +// Height returns full height (same as min for charts) +func (c *ChartBlock) Height() float64 { + return c.MinHeight() +} + +// Render draws the chart block +func (c *ChartBlock) Render(l *LayoutManager) { + l.EnsureSpace(c.MinHeight()) + c.RenderFunc(l) +} diff --git a/pkg/pdf/layout_test.go b/pkg/pdf/layout_test.go new file mode 100644 index 0000000..baa5e17 --- /dev/null +++ b/pkg/pdf/layout_test.go @@ -0,0 +1,178 @@ +package pdf + +import ( + "testing" + + "github.com/johnfercher/maroto/v2" + "github.com/johnfercher/maroto/v2/pkg/config" +) + +func TestLayoutManager_EnsureSpace(t *testing.T) { + tests := []struct { + name string + cursorY float64 + minHeight float64 + expectNewPage bool + }{ + { + name: "enough space available", + cursorY: 50, + minHeight: 100, + expectNewPage: false, + }, + { + name: "not enough space - need new page", + cursorY: UsableHeight - 50, + minHeight: 100, + expectNewPage: true, + }, + { + name: "exact fit", + cursorY: UsableHeight - 100, + minHeight: 100, + expectNewPage: false, + }, + { + name: "at page boundary", + cursorY: UsableHeight, + minHeight: 10, + expectNewPage: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewBuilder().Build() + m := maroto.New(cfg) + lm := NewLayoutManager(m) + lm.cursorY = tt.cursorY + + newPage := lm.EnsureSpace(tt.minHeight) + if newPage != tt.expectNewPage { + t.Errorf("EnsureSpace(%f) with cursorY=%f: got newPage=%v, want %v", + tt.minHeight, tt.cursorY, newPage, tt.expectNewPage) + } + }) + } +} + +func TestLayoutManager_Remaining(t *testing.T) { + cfg := config.NewBuilder().Build() + m := maroto.New(cfg) + lm := NewLayoutManager(m) + + // Initially should have full usable height + if got := lm.Remaining(); got != UsableHeight { + t.Errorf("Remaining() at start = %f, want %f", got, UsableHeight) + } + + // After adding a row, remaining should decrease + lm.AddSpacer(50) + expected := UsableHeight - 50 + if got := lm.Remaining(); got != expected { + t.Errorf("Remaining() after 50pt spacer = %f, want %f", got, expected) + } +} + +func TestLayoutManager_NewPage(t *testing.T) { + cfg := config.NewBuilder().Build() + m := maroto.New(cfg) + lm := NewLayoutManager(m) + + // Add some content + lm.AddSpacer(100) + if lm.cursorY != 100 { + t.Errorf("cursorY after spacer = %f, want 100", lm.cursorY) + } + + // Force new page + lm.NewPage() + if lm.cursorY != 0 { + t.Errorf("cursorY after NewPage = %f, want 0", lm.cursorY) + } + if lm.pageNum != 2 { + t.Errorf("pageNum after NewPage = %d, want 2", lm.pageNum) + } +} + +func TestSectionBlock_MinHeight(t *testing.T) { + tests := []struct { + name string + titleH float64 + itemH float64 + minItems int + itemCount int + wantHeight float64 + }{ + { + name: "more items than minimum", + titleH: 10, + itemH: 5, + minItems: 3, + itemCount: 10, + wantHeight: 10 + (5 * 3), // title + 3 items + }, + { + name: "fewer items than minimum", + titleH: 10, + itemH: 5, + minItems: 3, + itemCount: 2, + wantHeight: 10 + (5 * 2), // title + 2 items (all available) + }, + { + name: "exactly minimum items", + titleH: 10, + itemH: 5, + minItems: 3, + itemCount: 3, + wantHeight: 10 + (5 * 3), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items := make([]BlockItem, tt.itemCount) + block := &SectionBlock{ + TitleHeight: tt.titleH, + ItemHeight: tt.itemH, + MinItems: tt.minItems, + Items: items, + } + + if got := block.MinHeight(); got != tt.wantHeight { + t.Errorf("MinHeight() = %f, want %f", got, tt.wantHeight) + } + }) + } +} + +func TestSectionBlock_Height(t *testing.T) { + items := make([]BlockItem, 5) + block := &SectionBlock{ + TitleHeight: 10, + ItemHeight: 6, + Items: items, + } + + want := 10.0 + (6.0 * 5) // title + all 5 items + if got := block.Height(); got != want { + t.Errorf("Height() = %f, want %f", got, want) + } +} + +func TestPageDimensions(t *testing.T) { + // Verify constants are reasonable for A4 + if PageHeight != 297 { + t.Errorf("PageHeight = %f, want 297 (A4)", PageHeight) + } + if PageWidth != 210 { + t.Errorf("PageWidth = %f, want 210 (A4)", PageWidth) + } + + // Verify usable height calculation + expectedUsable := PageHeight - TopMargin - BottomMargin - FooterHeight + if UsableHeight != expectedUsable { + t.Errorf("UsableHeight = %f, want %f", UsableHeight, expectedUsable) + } +} diff --git a/pkg/pdf/tokens.go b/pkg/pdf/tokens.go new file mode 100644 index 0000000..5108a09 --- /dev/null +++ b/pkg/pdf/tokens.go @@ -0,0 +1,202 @@ +package pdf + +import ( + "github.com/johnfercher/maroto/v2/pkg/consts/align" + "github.com/johnfercher/maroto/v2/pkg/consts/fontstyle" + "github.com/johnfercher/maroto/v2/pkg/props" +) + +// Typography sizes +const ( + FontSizeTitle = 24.0 // Main document title + FontSizeH1 = 14.0 // Section headings + FontSizeH2 = 11.0 // Subsection headings + FontSizeBody = 10.0 // Regular body text + FontSizeSmall = 9.0 // Secondary text, bullets + FontSizeXSmall = 8.0 // Table cells, legends + FontSizeTiny = 7.0 // Fine print, table data + FontSizeFooter = 8.0 // Footer text +) + +// Row heights (consistent spacing) +const ( + RowHeightTitle = 30.0 // Main header row + RowHeightH1 = 10.0 // Section heading + RowHeightH2 = 8.0 // Subsection heading + RowHeightBody = 6.0 // Body text row + RowHeightTableRow = 6.0 // Table data row + RowHeightTableHead = 7.0 // Table header row + RowHeightChart = 60.0 // Chart with legend + RowHeightBarChart = 50.0 // Bar chart + RowHeightSpacer = 5.0 // Standard spacer + RowHeightSpacerLg = 8.0 // Large spacer between sections + RowHeightSpacerXl = 10.0 // Extra large spacer + RowHeightDesc = 4.0 // Description/subtitle row +) + +// Minimum items to show with a section title (prevents orphan headings) +const ( + MinListItems = 3 +) + +// Text style presets +var ( + // Headings + StyleTitle = props.Text{ + Size: FontSizeTitle, + Style: fontstyle.Bold, + Color: PurpleDark, + } + + StyleH1 = props.Text{ + Size: FontSizeH1, + Style: fontstyle.Bold, + Color: PurpleDark, + } + + StyleH2 = props.Text{ + Size: FontSizeH2, + Style: fontstyle.Bold, + Color: DarkGray, + } + + StyleH2Continued = props.Text{ + Size: FontSizeH2, + Style: fontstyle.Bold, + Color: DarkGray, + } + + // Body text + StyleBody = props.Text{ + Size: FontSizeBody, + Color: DarkGray, + } + + StyleBodyBold = props.Text{ + Size: FontSizeBody, + Style: fontstyle.Bold, + Color: Black, + } + + StyleSmall = props.Text{ + Size: FontSizeSmall, + Color: DarkGray, + } + + StyleSmallItalic = props.Text{ + Size: FontSizeSmall, + Color: DarkGray, + Style: fontstyle.Italic, + } + + StyleXSmall = props.Text{ + Size: FontSizeXSmall, + Color: DarkGray, + } + + // Table styles + StyleTableHeader = props.Text{ + Size: FontSizeXSmall, + Style: fontstyle.Bold, + Color: White, + Align: align.Center, + } + + StyleTableCell = props.Text{ + Size: FontSizeTiny, + Color: DarkGray, + } + + StyleTableCellRight = props.Text{ + Size: FontSizeTiny, + Color: DarkGray, + Align: align.Right, + } + + // Labels and values + StyleLabel = props.Text{ + Size: FontSizeBody, + Color: DarkGray, + } + + StyleValue = props.Text{ + Size: FontSizeBody, + Style: fontstyle.Bold, + Color: Black, + } + + StyleValueRight = props.Text{ + Size: FontSizeSmall, + Color: DarkGray, + Align: align.Right, + } + + // Footer + StyleFooter = props.Text{ + Size: FontSizeFooter, + Color: DarkGray, + } + + StyleFooterLink = props.Text{ + Size: FontSizeFooter, + Color: PurpleDark, + Align: align.Right, + } + + // Descriptions + StyleDescription = props.Text{ + Size: FontSizeXSmall, + Color: DarkGray, + } + + // Glossary styles + StyleGlossaryTerm = props.Text{ + Size: FontSizeXSmall, + Style: fontstyle.Bold, + Color: DarkGray, + } + + StyleGlossaryDef = props.Text{ + Size: FontSizeTiny, + Color: DarkGray, + } +) + +// Cell style presets +var ( + CellStyleHeader = props.Cell{ + BackgroundColor: PurpleLight, + } + + CellStyleAltRow = props.Cell{ + BackgroundColor: LightGray, + } + + CellStyleWhite = props.Cell{ + BackgroundColor: White, + } +) + +// WithTop returns a copy of the text props with a Top offset +func WithTop(p props.Text, top float64) props.Text { + p.Top = top + return p +} + +// WithLeft returns a copy of the text props with a Left offset +func WithLeft(p props.Text, left float64) props.Text { + p.Left = left + return p +} + +// WithColor returns a copy of the text props with a different color +func WithColor(p props.Text, color *props.Color) props.Text { + p.Color = color + return p +} + +// WithAlign returns a copy of the text props with alignment +func WithAlign(p props.Text, a align.Type) props.Text { + p.Align = a + return p +} diff --git a/pkg/report/report_client.go b/pkg/report/report_client.go index ea0c255..96639e9 100644 --- a/pkg/report/report_client.go +++ b/pkg/report/report_client.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/crowdsecurity/ipdex/cmd/ipdex/config" "github.com/crowdsecurity/ipdex/cmd/ipdex/style" "github.com/crowdsecurity/ipdex/pkg/database" "github.com/crowdsecurity/ipdex/pkg/display" @@ -198,6 +199,17 @@ func (r *ReportClient) GetExpiredIPFromReport(reportID uint) ([]string, error) { } func (r *ReportClient) Display(report *models.Report, stats *models.ReportStats, outputFormat string, withIPs bool, outputFilePath string) error { + // Check if output path exists and offer to create it + if outputFilePath != "" { + ok, err := config.EnsureOutputPath(outputFilePath) + if err != nil { + return fmt.Errorf("output path error: %w", err) + } + if !ok { + return fmt.Errorf("output directory does not exist and was not created") + } + } + displayer := display.NewDisplay() return displayer.DisplayReport(report, stats, outputFormat, withIPs, outputFilePath) }