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 0000000..536d624 Binary files /dev/null and b/pkg/pdf/assets/logo.png differ 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) }