diff --git a/cmd/ipdex/config/global.go b/cmd/ipdex/config/global.go index dd74861..7675067 100644 --- a/cmd/ipdex/config/global.go +++ b/cmd/ipdex/config/global.go @@ -2,6 +2,7 @@ package config var ( OutputFormat string + OutputFile string ForceRefresh bool Yes bool Detailed bool diff --git a/cmd/ipdex/config/options.go b/cmd/ipdex/config/options.go index 0813f3d..a04b3ea 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: + case display.JSONFormat, display.HumanFormat, display.PDFFormat: return true default: return false diff --git a/cmd/ipdex/file/file.go b/cmd/ipdex/file/file.go index fc90741..94d491f 100644 --- a/cmd/ipdex/file/file.go +++ b/cmd/ipdex/file/file.go @@ -206,7 +206,7 @@ func FileCommand(file string, forceRefresh bool, yes bool) { } } stats := reportClient.GetStats(report) - if err := reportClient.Display(report, stats, viper.GetString(config.OutputFormatOption), config.Detailed); err != nil { + if err := reportClient.Display(report, stats, viper.GetString(config.OutputFormatOption), config.Detailed, config.OutputFile); err != nil { style.Fatal(err.Error()) } if !reportExist && outputFormat == display.HumanFormat { diff --git a/cmd/ipdex/main.go b/cmd/ipdex/main.go index 932f137..796fd90 100644 --- a/cmd/ipdex/main.go +++ b/cmd/ipdex/main.go @@ -75,7 +75,8 @@ func init() { rootCmd.Flags().BoolVarP(&config.ForceRefresh, "refresh", "r", false, "Force refresh an IP or all the IPs of a report") rootCmd.Flags().BoolVarP(&config.Yes, "yes", "y", false, "Say automatically yes to the warning about the number of IPs to scan") rootCmd.PersistentFlags().BoolVarP(&config.Detailed, "detailed", "d", false, "Show all informations about an IP or a report") - rootCmd.PersistentFlags().StringVarP(&config.OutputFormat, "output", "o", "", "Output format: human or json") + rootCmd.PersistentFlags().StringVarP(&config.OutputFormat, "output", "o", "", "Output format: human, json, or pdf") + rootCmd.PersistentFlags().StringVar(&config.OutputFile, "output-file", "", "Output file path (e.g., report.pdf, report.json)") rootCmd.Flags().StringVarP(&config.ReportName, "name", "n", "", "Report name when scanning a file or making a search query") rootCmd.Flags().BoolVarP(&config.Batching, "batch", "b", false, "Use batching to request the CrowdSec API. Make sure you have a premium API key to use this feature.") } diff --git a/cmd/ipdex/report/show.go b/cmd/ipdex/report/show.go index cfa6535..77e2a50 100644 --- a/cmd/ipdex/report/show.go +++ b/cmd/ipdex/report/show.go @@ -54,7 +54,7 @@ func NewShowCommand() *cobra.Command { } else { style.Fatal("Please provide a report ID or file used in the report you want to show with `ipdex report show 1`") } - if err := reportClient.Display(report, report.Stats, viper.GetString(config.OutputFormatOption), config.Detailed); err != nil { + if err := reportClient.Display(report, report.Stats, viper.GetString(config.OutputFormatOption), config.Detailed, config.OutputFile); err != nil { style.Fatal(err.Error()) } fmt.Println() diff --git a/cmd/ipdex/search/search.go b/cmd/ipdex/search/search.go index efe7875..e826de5 100644 --- a/cmd/ipdex/search/search.go +++ b/cmd/ipdex/search/search.go @@ -118,7 +118,7 @@ func SearchCommand(query string, since string, maxResult int) { style.Fatalf("unable to create report: %s", err) } stats := reportClient.GetStats(report) - if err := reportClient.Display(report, stats, viper.GetString(config.OutputFormatOption), config.Detailed); err != nil { + if err := reportClient.Display(report, stats, viper.GetString(config.OutputFormatOption), config.Detailed, config.OutputFile); err != nil { style.Fatal(err.Error()) } if outputFormat == display.HumanFormat { diff --git a/go.mod b/go.mod index 1943884..864a868 100644 --- a/go.mod +++ b/go.mod @@ -20,18 +20,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/johnfercher/maroto/v2 v2.3.3 // 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 @@ -43,7 +50,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 @@ -54,13 +64,16 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/wcharczuk/go-chart/v2 v2.1.2 // indirect 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..ff48dc9 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,7 @@ 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/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 +190,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 +200,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 +240,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 +264,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 +285,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 2d0ce3c..205d700 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -12,6 +12,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" @@ -23,6 +24,7 @@ import ( const ( JSONFormat = "json" HumanFormat = "human" + PDFFormat = "pdf" maxCVEDisplay = 3 maxBehaviorsDisplay = 3 maxClassificationDisplay = 3 @@ -285,7 +287,7 @@ func displayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool return nil } -func (d *Display) DisplayReport(item *models.Report, stats *models.ReportStats, format string, withIPs bool) error { +func (d *Display) DisplayReport(item *models.Report, stats *models.ReportStats, format string, withIPs bool, outputFilePath string) error { switch format { case HumanFormat: if err := displayReport(item, stats, withIPs); err != nil { @@ -295,6 +297,14 @@ func (d *Display) DisplayReport(item *models.Report, stats *models.ReportStats, if err := displayReportJSON(item, stats); err != nil { return err } + case PDFFormat: + if outputFilePath == "" { + return fmt.Errorf("--output-file is required for PDF format (e.g., --output-file report.pdf)") + } + if err := pdf.GenerateReport(item, stats, withIPs, outputFilePath); err != nil { + return err + } + fmt.Printf("PDF report saved to: %s\n", outputFilePath) default: return fmt.Errorf("format '%s' not supported", format) } 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..425bbf7 --- /dev/null +++ b/pkg/pdf/charts.go @@ -0,0 +1,174 @@ +package pdf + +import ( + "bytes" + "image/color" + "sort" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" + + "github.com/crowdsecurity/ipdex/pkg/models" +) + +// 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: 400, + Height: 300, + Values: values, + Background: chart.Style{ + FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + }, + } + + 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, + }, + }) + } + + barChart := chart.BarChart{ + Title: title, + TitleStyle: chart.StyleTextDefaults(), + Width: 400, + Height: 250, + BarWidth: 30, + Bars: bars, + Background: chart.Style{ + FillColor: drawing.Color{R: 255, G: 255, B: 255, A: 255}, + }, + XAxis: chart.Style{ + FontSize: 8, + FontColor: drawing.Color{R: 60, G: 60, B: 60, A: 255}, + }, + } + + 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] + "..." +} + +// DrawingColorToColor converts drawing.Color to color.RGBA +func DrawingColorToColor(c drawing.Color) color.RGBA { + return color.RGBA{R: c.R, G: c.G, B: c.B, A: c.A} +} diff --git a/pkg/pdf/colors.go b/pkg/pdf/colors.go new file mode 100644 index 0000000..2113839 --- /dev/null +++ b/pkg/pdf/colors.go @@ -0,0 +1,35 @@ +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"] +} diff --git a/pkg/pdf/generator.go b/pkg/pdf/generator.go new file mode 100644 index 0000000..b70e645 --- /dev/null +++ b/pkg/pdf/generator.go @@ -0,0 +1,504 @@ +package pdf + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/johnfercher/maroto/v2" + "github.com/johnfercher/maroto/v2/pkg/components/col" + "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 path +func GenerateReport(report *models.Report, stats *models.ReportStats, withIPs bool, outputPath string) error { + g := &generator{ + report: report, + stats: stats, + withIPs: withIPs, + } + + return g.generate(outputPath) +} + +type generator struct { + report *models.Report + stats *models.ReportStats + withIPs bool +} + +func (g *generator) generate(outputPath string) error { + cfg := config.NewBuilder(). + WithPageNumber(). + WithLeftMargin(10). + WithTopMargin(10). + WithRightMargin(10). + Build() + + m := maroto.New(cfg) + + // Add header + g.addHeader(m) + + // Add general info section + g.addGeneralInfo(m) + + // Add charts section + if err := g.addCharts(m); err != nil { + return fmt.Errorf("failed to add charts: %w", err) + } + + // Add statistics section + g.addStatistics(m) + + // Add IP table if requested + if g.withIPs && len(g.report.IPs) > 0 { + g.addIPTable(m) + } + + // Add footer + g.addFooter(m) + + // 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) + } + + return nil +} + +func (g *generator) addHeader(m core.Maroto) { + m.AddRow(30, + col.New(3).Add( + image.NewFromBytes(LogoPNG, extension.Png, props.Rect{ + Center: true, + Percent: 80, + }), + ), + col.New(9).Add( + text.New("ipdex Report", props.Text{ + Size: 24, + Style: fontstyle.Bold, + Align: align.Left, + Color: PurpleDark, + Top: 5, + }), + text.New(g.report.Name, props.Text{ + Size: 14, + Align: align.Left, + Color: DarkGray, + Top: 18, + }), + ), + ) + + // Purple line separator + m.AddRow(2, + col.New(12).Add( + line.New(props.Line{ + Color: PurpleDark, + Thickness: 2, + }), + ), + ) + + m.AddRow(5) // Spacer +} + +func (g *generator) addGeneralInfo(m core.Maroto) { + // Section title + m.AddRow(8, + col.New(12).Add( + text.New("General Information", props.Text{ + Size: 14, + Style: fontstyle.Bold, + Color: PurpleDark, + }), + ), + ) + + // Info rows + infoStyle := props.Text{Size: 10, Color: DarkGray} + valueStyle := props.Text{Size: 10, Style: fontstyle.Bold, Color: Black} + + addInfoRow := func(m core.Maroto, label, value string) { + m.AddRow(6, + col.New(4).Add(text.New(label+":", infoStyle)), + col.New(8).Add(text.New(value, valueStyle)), + ) + } + + addInfoRow(m, "Report ID", strconv.Itoa(int(g.report.ID))) + addInfoRow(m, "Creation Date", g.report.CreatedAt.Format("2006-01-02 15:04:05")) + + if g.report.IsFile { + addInfoRow(m, "File Path", g.report.FilePath) + addInfoRow(m, "SHA256", truncateMiddle(g.report.FileHash, 50)) + } + + if g.report.IsQuery { + addInfoRow(m, "Query", g.report.Query) + addInfoRow(m, "Since", g.report.Since) + } + + addInfoRow(m, "Total IPs", strconv.Itoa(g.stats.NbIPs)) + + knownPercent := float64(g.stats.NbIPs-g.stats.NbUnknownIPs) / float64(g.stats.NbIPs) * 100 + blocklistPercent := float64(g.stats.IPsBlockedByBlocklist) / float64(g.stats.NbIPs) * 100 + + addInfoRow(m, "Known IPs", fmt.Sprintf("%d (%.1f%%)", g.stats.NbIPs-g.stats.NbUnknownIPs, knownPercent)) + addInfoRow(m, "IPs in Blocklist", fmt.Sprintf("%d (%.1f%%)", g.stats.IPsBlockedByBlocklist, blocklistPercent)) + + m.AddRow(8) // Spacer +} + +func (g *generator) addCharts(m core.Maroto) error { + // Section title + m.AddRow(8, + col.New(12).Add( + text.New("Threat Overview", props.Text{ + Size: 14, + Style: fontstyle.Bold, + Color: PurpleDark, + }), + ), + ) + + // Add reputation distribution as proper rows + if len(g.stats.TopReputation) > 0 { + m.AddRow(6, + col.New(12).Add( + text.New("Reputation Distribution", props.Text{ + Size: 11, + Style: fontstyle.Bold, + Color: DarkGray, + }), + ), + ) + + topRep := getTopN(g.stats.TopReputation, maxTopDisplay) + for _, item := range topRep { + percent := float64(item.Value) / float64(g.stats.NbIPs) * 100 + color := GetReputationColor(item.Key) + m.AddRow(5, + col.New(6).Add( + text.New(cases.Title(language.Und).String(item.Key), props.Text{ + Size: 9, + Color: color, + Left: 10, + }), + ), + col.New(6).Add( + text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, percent), props.Text{ + Size: 9, + Color: color, + }), + ), + ) + } + m.AddRow(5) // Spacer + } + + // Add top countries as text table + if len(g.stats.TopCountries) > 0 { + g.addTopItemsSection(m, "Top Countries", g.stats.TopCountries) + } + + // Add top behaviors as text table + if len(g.stats.TopBehaviors) > 0 { + g.addTopItemsSection(m, "Top Behaviors", g.stats.TopBehaviors) + } + + m.AddRow(5) // Spacer + return nil +} + +func (g *generator) addTopItemsSection(m core.Maroto, title string, data map[string]int) { + topItems := getTopN(data, maxTopDisplay) + if len(topItems) == 0 { + return + } + + m.AddRow(6, + col.New(12).Add( + text.New(title, props.Text{ + Size: 11, + Style: fontstyle.Bold, + Color: DarkGray, + }), + ), + ) + + for _, item := range topItems { + percent := float64(item.Value) / float64(g.stats.NbIPs) * 100 + m.AddRow(5, + col.New(6).Add( + text.New(truncate(item.Key, 30), props.Text{ + Size: 9, + Color: DarkGray, + Left: 10, + }), + ), + col.New(6).Add( + text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, percent), props.Text{ + Size: 9, + Color: DarkGray, + }), + ), + ) + } + + m.AddRow(5) // Spacer +} + +func (g *generator) addStatistics(m core.Maroto) { + // Section title + m.AddRow(8, + col.New(12).Add( + text.New("Statistics", props.Text{ + Size: 14, + Style: fontstyle.Bold, + Color: PurpleDark, + }), + ), + ) + + // Add statistics in a grid layout + g.addStatTable(m, "Top Classifications", g.stats.TopClassifications) + g.addStatTable(m, "Top Blocklists", g.stats.TopBlocklists) + g.addStatTable(m, "Top CVEs", g.stats.TopCVEs) + g.addStatTable(m, "Top IP Ranges", g.stats.TopIPRange) + g.addStatTable(m, "Top Autonomous Systems", g.stats.TopAS) + + m.AddRow(5) // Spacer +} + +func (g *generator) addStatTable(m core.Maroto, title string, data map[string]int) { + if len(data) == 0 { + return + } + + topItems := getTopN(data, maxTopDisplay) + + // Title + m.AddRow(6, + col.New(12).Add( + text.New(title, props.Text{ + Size: 11, + Style: fontstyle.Bold, + Color: DarkGray, + }), + ), + ) + + // Items + for _, item := range topItems { + percent := float64(item.Value) / float64(g.stats.NbIPs) * 100 + m.AddRow(5, + col.New(8).Add( + text.New(truncate(item.Key, 40), props.Text{ + Size: 9, + Color: DarkGray, + Left: 5, + }), + ), + col.New(4).Add( + text.New(fmt.Sprintf("%d (%.1f%%)", item.Value, percent), props.Text{ + Size: 9, + Color: DarkGray, + Align: align.Right, + }), + ), + ) + } + + m.AddRow(3) // Spacer between tables +} + +func (g *generator) addIPTable(m core.Maroto) { + // Section title + m.AddRow(8, + col.New(12).Add( + text.New("IP Address Details", props.Text{ + Size: 14, + Style: fontstyle.Bold, + Color: PurpleDark, + }), + ), + ) + + // Table header + headerStyle := props.Cell{ + BackgroundColor: PurpleLight, + } + headerTextStyle := props.Text{ + Size: 8, + Style: fontstyle.Bold, + Color: White, + Align: align.Center, + } + + m.AddRow(7, + col.New(2).Add(text.New("IP", headerTextStyle)).WithStyle(&headerStyle), + col.New(1).Add(text.New("Country", headerTextStyle)).WithStyle(&headerStyle), + col.New(2).Add(text.New("AS Name", headerTextStyle)).WithStyle(&headerStyle), + col.New(2).Add(text.New("Reputation", headerTextStyle)).WithStyle(&headerStyle), + col.New(1).Add(text.New("Conf.", headerTextStyle)).WithStyle(&headerStyle), + col.New(2).Add(text.New("Behaviors", headerTextStyle)).WithStyle(&headerStyle), + col.New(2).Add(text.New("Range", headerTextStyle)).WithStyle(&headerStyle), + ) + + // Table rows + rowStyle := props.Cell{ + BorderType: border.Bottom, + BorderColor: LightGray, + } + cellTextStyle := props.Text{ + Size: 7, + Color: DarkGray, + } + + for i, ip := range g.report.IPs { + // 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: 7, + Color: repColor, + Style: fontstyle.Bold, + } + + m.AddRow(6, + col.New(2).Add(text.New(ip.Ip, cellTextStyle)).WithStyle(&rowStyle), + col.New(1).Add(text.New(country, cellTextStyle)).WithStyle(&rowStyle), + col.New(2).Add(text.New(asName, cellTextStyle)).WithStyle(&rowStyle), + col.New(2).Add(text.New(reputation, repTextStyle)).WithStyle(&rowStyle), + col.New(1).Add(text.New(confidence, cellTextStyle)).WithStyle(&rowStyle), + col.New(2).Add(text.New(behaviors, cellTextStyle)).WithStyle(&rowStyle), + col.New(2).Add(text.New(ipRange, cellTextStyle)).WithStyle(&rowStyle), + ) + } +} + +func (g *generator) addFooter(m core.Maroto) { + m.AddRow(10) // Spacer + + // Separator line + m.AddRow(2, + col.New(12).Add( + line.New(props.Line{ + Color: LightGray, + Thickness: 1, + }), + ), + ) + + // Footer content + m.AddRow(8, + col.New(6).Add( + text.New("Generated by ipdex - CrowdSec CTI Tool", props.Text{ + Size: 8, + Color: DarkGray, + }), + text.New(time.Now().Format("2006-01-02 15:04:05"), props.Text{ + Size: 8, + Color: DarkGray, + Top: 10, + }), + ), + col.New(6).Add( + text.New("https://crowdsec.net", props.Text{ + Size: 8, + Color: PurpleDark, + Align: align.Right, + }), + text.New("https://app.crowdsec.net", props.Text{ + Size: 8, + Color: PurpleDark, + Align: align.Right, + Top: 10, + }), + ), + ) +} + +// 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:] +} + +// GetOutputPath constructs the full PDF output path +func GetOutputPath(outputDir string, reportID uint) string { + return filepath.Join(outputDir, fmt.Sprintf("report_%d.pdf", reportID)) +} diff --git a/pkg/report/report_client.go b/pkg/report/report_client.go index ad5e41d..ea0c255 100644 --- a/pkg/report/report_client.go +++ b/pkg/report/report_client.go @@ -197,9 +197,9 @@ func (r *ReportClient) GetExpiredIPFromReport(reportID uint) ([]string, error) { return ret, nil } -func (r *ReportClient) Display(report *models.Report, stats *models.ReportStats, outputFormat string, withIPs bool) error { +func (r *ReportClient) Display(report *models.Report, stats *models.ReportStats, outputFormat string, withIPs bool, outputFilePath string) error { displayer := display.NewDisplay() - return displayer.DisplayReport(report, stats, outputFormat, withIPs) + return displayer.DisplayReport(report, stats, outputFormat, withIPs, outputFilePath) } func (r *ReportClient) DeleteExpiredReports(expiration string) error {