From 36e27e503094b8e80e82848526b119c62ff89799 Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 2 Feb 2026 16:43:00 +0000 Subject: [PATCH] Add PDF export feature Add PDF report generation with CrowdSec branding, statistics tables, and IP listing. Uses maroto v2 library for PDF generation. - Add -o pdf format option - Add --output-file flag for specifying output filename - New pkg/pdf package with generator, colors, and embedded logo - Includes report header, general info, threat overview, statistics Co-Authored-By: Claude Opus 4.5 --- cmd/ipdex/config/global.go | 1 + cmd/ipdex/config/options.go | 2 +- cmd/ipdex/file/file.go | 2 +- cmd/ipdex/main.go | 3 +- cmd/ipdex/report/show.go | 2 +- cmd/ipdex/search/search.go | 2 +- go.mod | 13 + go.sum | 61 +++++ pkg/display/display.go | 12 +- pkg/pdf/assets.go | 8 + pkg/pdf/assets/logo.png | Bin 0 -> 27905 bytes pkg/pdf/charts.go | 174 +++++++++++++ pkg/pdf/colors.go | 35 +++ pkg/pdf/generator.go | 504 ++++++++++++++++++++++++++++++++++++ pkg/report/report_client.go | 4 +- 15 files changed, 815 insertions(+), 8 deletions(-) create mode 100644 pkg/pdf/assets.go create mode 100644 pkg/pdf/assets/logo.png create mode 100644 pkg/pdf/charts.go create mode 100644 pkg/pdf/colors.go create mode 100644 pkg/pdf/generator.go 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 0000000000000000000000000000000000000000..536d6244c72e78c0a04804b90c4296a8111d85d2 GIT binary patch literal 27905 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 Vr!&gC4d5*>IC+qYEKJ%s=zk39)Oi2^ literal 0 HcmV?d00001 diff --git a/pkg/pdf/charts.go b/pkg/pdf/charts.go new file mode 100644 index 0000000..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 {