From 6e95416aee4e8e256b51bccb9d30558d21d8768a Mon Sep 17 00:00:00 2001 From: Kevin KADOSH Date: Thu, 4 Dec 2025 17:23:26 +0100 Subject: [PATCH 1/7] Fix reports with too many IPs --- pkg/database/database.go | 2 +- pkg/database/report.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/database/database.go b/pkg/database/database.go index 88bb754..2c5030d 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -13,7 +13,7 @@ type Client struct { func NewClient(sqliteDBPath string) (*Client, error) { client := &Client{} - db, err := gorm.Open(sqlite.Open(sqliteDBPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + db, err := gorm.Open(sqlite.Open(sqliteDBPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) if err != nil { return client, err } diff --git a/pkg/database/report.go b/pkg/database/report.go index 4b882ba..177f91a 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -74,6 +74,7 @@ func (r *ReportClient) FindById(reportID uint) (*Report, error) { return nil, result.Error } + // Load IPs using Association API err := r.db.Model(&report).Association("IPs").Find(&report.IPs) if err != nil { return nil, err @@ -98,6 +99,7 @@ func (r *ReportClient) FindByHash(filepath string) (*Report, error) { return nil, result.Error } + // Load IPs using Association API err = r.db.Model(&report).Association("IPs").Find(&report.IPs) if err != nil { return nil, err @@ -191,6 +193,7 @@ func (r *ReportClient) FilePathExist(filePath string) (*Report, bool, error) { return nil, false, nil } + // Load IPs using Association API err := r.db.Model(&reports[0]).Association("IPs").Find(&reports[0].IPs) if err != nil { return nil, false, err From 02f285ae033f7c37942647b9945cf2c28990770e Mon Sep 17 00:00:00 2001 From: Kevin KADOSH Date: Thu, 4 Dec 2025 17:24:31 +0100 Subject: [PATCH 2/7] remove log debug --- pkg/database/database.go | 2 +- pkg/database/report.go | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/database/database.go b/pkg/database/database.go index 2c5030d..88bb754 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -13,7 +13,7 @@ type Client struct { func NewClient(sqliteDBPath string) (*Client, error) { client := &Client{} - db, err := gorm.Open(sqlite.Open(sqliteDBPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) + db, err := gorm.Open(sqlite.Open(sqliteDBPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { return client, err } diff --git a/pkg/database/report.go b/pkg/database/report.go index 177f91a..4b882ba 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -74,7 +74,6 @@ func (r *ReportClient) FindById(reportID uint) (*Report, error) { return nil, result.Error } - // Load IPs using Association API err := r.db.Model(&report).Association("IPs").Find(&report.IPs) if err != nil { return nil, err @@ -99,7 +98,6 @@ func (r *ReportClient) FindByHash(filepath string) (*Report, error) { return nil, result.Error } - // Load IPs using Association API err = r.db.Model(&report).Association("IPs").Find(&report.IPs) if err != nil { return nil, err @@ -193,7 +191,6 @@ func (r *ReportClient) FilePathExist(filePath string) (*Report, bool, error) { return nil, false, nil } - // Load IPs using Association API err := r.db.Model(&reports[0]).Association("IPs").Find(&reports[0].IPs) if err != nil { return nil, false, err From d2e19c7ff47d3e04b30e9c2c7da86d6f035c4a75 Mon Sep 17 00:00:00 2001 From: jdv Date: Thu, 10 Jul 2025 15:15:05 +0200 Subject: [PATCH 3/7] adding csv output creating report and details csv files proper csv format split from file output system + some facorization --- cmd/ipdex/config/global.go | 13 +- 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 +- pkg/display/display.go | 1005 ++++++++++++++++++++++++++++++++++- pkg/display/formats.go | 298 +++++++++++ pkg/report/report_client.go | 4 +- 9 files changed, 1289 insertions(+), 42 deletions(-) create mode 100644 pkg/display/formats.go diff --git a/cmd/ipdex/config/global.go b/cmd/ipdex/config/global.go index dd74861..f781397 100644 --- a/cmd/ipdex/config/global.go +++ b/cmd/ipdex/config/global.go @@ -1,10 +1,11 @@ package config var ( - OutputFormat string - ForceRefresh bool - Yes bool - Detailed bool - ReportName string - Batching bool + OutputFormat string + OutputFilePath string + ForceRefresh bool + Yes bool + Detailed bool + ReportName string + Batching bool ) diff --git a/cmd/ipdex/config/options.go b/cmd/ipdex/config/options.go index 0813f3d..14936e6 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.CSVFormat: return true default: return false diff --git a/cmd/ipdex/file/file.go b/cmd/ipdex/file/file.go index fc90741..7340750 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.OutputFilePath); 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..c3d529f 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 csv") + rootCmd.PersistentFlags().StringVar(&config.OutputFilePath, "output-path", "", "Output file path for saving reports in the format specified by -o (saves report and details files separately)") 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..60a98a4 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.OutputFilePath); err != nil { style.Fatal(err.Error()) } fmt.Println() diff --git a/cmd/ipdex/search/search.go b/cmd/ipdex/search/search.go index efe7875..e95175e 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.OutputFilePath); err != nil { style.Fatal(err.Error()) } if outputFormat == display.HumanFormat { diff --git a/pkg/display/display.go b/pkg/display/display.go index 2d0ce3c..e1be8b5 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -1,6 +1,7 @@ package display import ( + "encoding/csv" "encoding/json" "fmt" "os" @@ -23,6 +24,7 @@ import ( const ( JSONFormat = "json" HumanFormat = "human" + CSVFormat = "csv" maxCVEDisplay = 3 maxBehaviorsDisplay = 3 maxClassificationDisplay = 3 @@ -55,13 +57,17 @@ func NewRowDisplay(writer *tabwriter.Writer, maxSpace int) *RowDisplay { func (d *Display) DisplayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, format string, detailed bool) error { switch format { case HumanFormat: - if err := displayIP(item, ipLastRefresh, detailed); err != nil { + if err := displayIPHuman(item, ipLastRefresh, detailed); err != nil { return err } case JSONFormat: if err := displayIPJSON(item); err != nil { return err } + case CSVFormat: + if err := displayIPCSV(item, ipLastRefresh); err != nil { + return err + } default: return fmt.Errorf("format '%s' not supported", format) } @@ -77,7 +83,36 @@ func displayIPJSON(item *cticlient.SmokeItem) error { return nil } -func displayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error { +func displayIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { + w := csv.NewWriter(os.Stdout) + defer w.Flush() + + // Build reputation with false positives if applicable + reputation := item.Reputation + if reputation == "safe" && len(item.Classifications.FalsePositives) > 0 { + reputation = fmt.Sprintf("%s (%s)", reputation, Format(item.Classifications.FalsePositives, FormatCSV)) + } + + // Extract timestamps + history := Format(item.History, FormatCSV) + timestamps := strings.Split(history, ",") + firstSeen, lastSeen := timestamps[0], timestamps[1] + + w.Write([]string{"IP", "Reputation", "Confidence", "Country", "Autonomous System", + "Reverse DNS", "Range", "First Seen", "Last Seen", "Console URL", + "Last Local Refresh", "Behaviors", "False Positives", "Classifications", "Blocklists", "CVEs"}) + + w.Write([]string{item.Ip, reputation, item.Confidence, Format(item.Location, FormatCSV), + Format(item.AsName, FormatCSV), Format(item.ReverseDNS, FormatCSV), Format(item.IpRange, FormatCSV), + firstSeen, lastSeen, fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), + ipLastRefresh.Format("2006-01-02 15:04:05"), Format(item.Behaviors, FormatCSV), + Format(item.Classifications.FalsePositives, FormatCSV), Format(item.Classifications.Classifications, FormatCSV), + Format(item.References, FormatCSV), Format(item.CVEs, FormatCSV)}) + + return nil +} + +func displayIPHuman(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error { keyStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("15")) @@ -285,49 +320,49 @@ 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 { + if err := displayReportHuman(item, stats, withIPs); err != nil { return err } case JSONFormat: if err := displayReportJSON(item, stats); err != nil { return err } + case CSVFormat: + // For CSV format, display in CSV format on screen + if err := displayReportCSV(item, stats, withIPs); err != nil { + return err + } default: return fmt.Errorf("format '%s' not supported", format) } - return nil -} - -func displayReportJSON(item *models.Report, stats *models.ReportStats) error { - jsonData, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Printf("%s\n", jsonData) - jsonData, err = json.MarshalIndent(stats, "", " ") - if err != nil { - return err + // If output file path is provided, save files in the specified format + if outputFilePath != "" { + switch format { + case HumanFormat: + if err := saveReportHuman(item, stats, withIPs, outputFilePath); err != nil { + return err + } + case JSONFormat: + if err := saveReportJSON(item, stats, withIPs, outputFilePath); err != nil { + return err + } + case CSVFormat: + if err := saveReportCSV(item, stats, withIPs, outputFilePath); err != nil { + return err + } + default: + return fmt.Errorf("format '%s' not supported for file output", format) + } } - fmt.Printf("%s\n", jsonData) return nil } -func TruncateWithEllipsis(s string, max int) string { - if len(s) <= max { - return s - } - if max <= 3 { - return "..." - } - return s[:max-3] + "..." -} - -func displayReport(report *models.Report, stats *models.ReportStats, withIPs bool) error { +func displayReportHuman(report *models.Report, stats *models.ReportStats, withIPs bool) error { keyStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("15")) @@ -526,7 +561,6 @@ func displayReport(report *models.Report, stats *models.ReportStats, withIPs boo "N/A", "N/A", "N/A", - "N/A", }) continue } @@ -552,3 +586,916 @@ func displayReport(report *models.Report, stats *models.ReportStats, withIPs boo return nil } + +func displayReportJSON(item *models.Report, stats *models.ReportStats) error { + jsonData, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Printf("%s\n", jsonData) + + jsonData, err = json.MarshalIndent(stats, "", " ") + if err != nil { + return err + } + fmt.Printf("%s\n", jsonData) + + return nil +} + +func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bool) error { + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + // Write general section + writer.Write([]string{"General", "", ""}) + writer.Write([]string{"", "", ""}) + writer.Write([]string{"Report ID", strconv.Itoa(int(item.ID)), ""}) + writer.Write([]string{"Report Name", item.Name, ""}) + writer.Write([]string{"Creation Date", item.CreatedAt.Format("2006-01-02 15:04:05"), ""}) + + if item.IsFile { + writer.Write([]string{"File path", item.FilePath, ""}) + writer.Write([]string{"SHA256", item.FileHash, ""}) + } + + if item.IsQuery { + writer.Write([]string{"Query", item.Query, ""}) + writer.Write([]string{"Since Duration", item.Since, ""}) + writer.Write([]string{"Since Time", item.SinceTime.Format("2006-01-02 15:04:05"), ""}) + } + + writer.Write([]string{"Number of IPs", strconv.Itoa(len(item.IPs)), ""}) + + knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 + ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 + + writer.Write([]string{"Number of known IPs", fmt.Sprintf("%d", stats.NbIPs-stats.NbUnknownIPs), fmt.Sprintf("%.0f%%", knownIPPercent)}) + writer.Write([]string{"Number of IPs in Blocklist", fmt.Sprintf("%d", stats.IPsBlockedByBlocklist), fmt.Sprintf("%.0f%%", ipsInBlocklistPercent)}) + + // Empty line before Stats section + writer.Write([]string{"", "", ""}) + + // Stats section + writer.Write([]string{"Stats", "", ""}) + writer.Write([]string{"", "", ""}) + + // Top Reputation + TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) + if len(TopReputation) > 0 { + writer.Write([]string{"Top Reputation", "", ""}) + for _, stat := range TopReputation { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top Classifications + topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) + if len(topClassification) > 0 { + writer.Write([]string{"Top Classifications", "", ""}) + for _, stat := range topClassification { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top Behaviors + topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) + if len(topBehaviors) > 0 { + writer.Write([]string{"Top Behaviors", "", ""}) + for _, stat := range topBehaviors { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top Blocklists + topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) + if len(topBlocklists) > 0 { + writer.Write([]string{"Top Blocklists", "", ""}) + for _, stat := range topBlocklists { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top CVEs + topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) + if len(topCVEs) > 0 { + writer.Write([]string{"Top CVEs", "", ""}) + for _, stat := range topCVEs { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top IP Ranges + TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) + if len(TopIPRange) > 0 { + writer.Write([]string{"Top IP Ranges", "", ""}) + for _, stat := range TopIPRange { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top Autonomous Systems + topAS := getTopN(stats.TopAS, maxTopDisplayReport) + if len(topAS) > 0 { + writer.Write([]string{"Top Autonomous Systems", "", ""}) + for _, stat := range topAS { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // Top Countries + topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) + if len(topCountry) > 0 { + writer.Write([]string{"Top Countries", "", ""}) + for _, stat := range topCountry { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + writer.Write([]string{"", "", ""}) + } + + // If detailed IP information is requested, show it + if withIPs { + writer.Write([]string{"", "", ""}) + writer.Write([]string{"IP Details", "", ""}) + writer.Write([]string{ + "IP", "Country", "AS Name", "Reputation", "Confidence", + "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", + }) + + for _, ipItem := range item.IPs { + country := "N/A" + ipRange := "N/A" + asName := "N/A" + reverseDNS := "N/A" + + if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { + reverseDNS = *ipItem.ReverseDNS + } + if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { + country = *ipItem.Location.Country + } + if ipItem.IpRange != nil && *ipItem.IpRange != "" { + ipRange = *ipItem.IpRange + } + if ipItem.AsName != nil && *ipItem.AsName != "" { + asName = *ipItem.AsName + } + + behaviors := "" + for i, behavior := range ipItem.Behaviors { + if i > 0 { + behaviors += ", " + } + behaviors += behavior.Label + } + if behaviors == "" { + behaviors = "N/A" + } + + classif := "N/A" + if len(ipItem.Classifications.Classifications) > 0 { + for _, classification := range ipItem.Classifications.Classifications { + if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { + continue + } + classif = classification.Label + } + } + if len(ipItem.Classifications.FalsePositives) > 0 { + for _, classification := range ipItem.Classifications.FalsePositives { + classif = classification.Label + } + } + + firstSeen := "N/A" + lastSeen := "N/A" + if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { + firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] + } + if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { + lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] + } + + reputation := ipItem.Reputation + confidence := ipItem.Confidence + if reputation == "" { + reputation = "N/A" + confidence = "N/A" + } + + writer.Write([]string{ + ipItem.Ip, country, asName, reputation, confidence, + reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, + }) + } + } + + return nil +} + +func TruncateWithEllipsis(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return "..." + } + return s[:max-3] + "..." +} + +func saveReportCSV(report *models.Report, stats *models.ReportStats, withIPs bool, outputFilePath string) error { + // Always save the report summary + reportFilename := fmt.Sprintf("%s/report-%d.csv", outputFilePath, report.ID) + reportFile, err := os.Create(reportFilename) + if err != nil { + return fmt.Errorf("failed to create report CSV file %s: %v", reportFilename, err) + } + defer reportFile.Close() + + reportWriter := csv.NewWriter(reportFile) + defer reportWriter.Flush() + + // Collect all CSV rows + var csvRows [][]string + + // General section + csvRows = append(csvRows, []string{"General", "", ""}) + csvRows = append(csvRows, []string{"", "", ""}) + csvRows = append(csvRows, []string{"Report ID", strconv.Itoa(int(report.ID)), ""}) + csvRows = append(csvRows, []string{"Report Name", report.Name, ""}) + csvRows = append(csvRows, []string{"Creation Date", report.CreatedAt.Format("2006-01-02 15:04:05"), ""}) + + if report.IsFile { + csvRows = append(csvRows, []string{"File path", report.FilePath, ""}) + csvRows = append(csvRows, []string{"SHA256", report.FileHash, ""}) + } + + if report.IsQuery { + csvRows = append(csvRows, []string{"Query", report.Query, ""}) + csvRows = append(csvRows, []string{"Since Duration", report.Since, ""}) + csvRows = append(csvRows, []string{"Since Time", report.SinceTime.Format("2006-01-02 15:04:05"), ""}) + } + + csvRows = append(csvRows, []string{"Number of IPs", strconv.Itoa(len(report.IPs)), ""}) + + knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 + ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 + + csvRows = append(csvRows, []string{"Number of known IPs", fmt.Sprintf("%d", stats.NbIPs-stats.NbUnknownIPs), fmt.Sprintf("%.0f%%", knownIPPercent)}) + csvRows = append(csvRows, []string{"Number of IPs in Blocklist", fmt.Sprintf("%d", stats.IPsBlockedByBlocklist), fmt.Sprintf("%.0f%%", ipsInBlocklistPercent)}) + + // Empty line before Stats section + csvRows = append(csvRows, []string{"", "", ""}) + + // Stats section + csvRows = append(csvRows, []string{"Stats", "", ""}) + csvRows = append(csvRows, []string{"", "", ""}) + + // Top Reputation + TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) + if len(TopReputation) > 0 { + csvRows = append(csvRows, []string{"🌟 Top Reputation", "", ""}) + for _, stat := range TopReputation { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top Classifications + topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) + if len(topClassification) > 0 { + csvRows = append(csvRows, []string{"🗂️ Top Classifications", "", ""}) + for _, stat := range topClassification { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top Behaviors + topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) + if len(topBehaviors) > 0 { + csvRows = append(csvRows, []string{"🤖 Top Behaviors", "", ""}) + for _, stat := range topBehaviors { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top Blocklists + topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) + if len(topBlocklists) > 0 { + csvRows = append(csvRows, []string{"⛔ Top Blocklists", "", ""}) + for _, stat := range topBlocklists { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top CVEs + topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) + if len(topCVEs) > 0 { + csvRows = append(csvRows, []string{"💥 Top CVEs", "", ""}) + for _, stat := range topCVEs { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top IP Ranges + TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) + if len(TopIPRange) > 0 { + csvRows = append(csvRows, []string{"🌐 Top IP Ranges", "", ""}) + for _, stat := range TopIPRange { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top Autonomous Systems + topAS := getTopN(stats.TopAS, maxTopDisplayReport) + if len(topAS) > 0 { + csvRows = append(csvRows, []string{"🛰️ Top Autonomous Systems", "", ""}) + for _, stat := range topAS { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Top Countries + topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) + if len(topCountry) > 0 { + csvRows = append(csvRows, []string{"🌎 Top Countries", "", ""}) + for _, stat := range topCountry { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + } + csvRows = append(csvRows, []string{"", "", ""}) + } + + // Write all rows at once + for _, row := range csvRows { + if err := reportWriter.Write(row); err != nil { + return fmt.Errorf("failed to write CSV row: %v", err) + } + } + + fmt.Printf("Report summary saved to: %s\n", reportFilename) + + // If detailed IP information is requested, save to a separate file + if withIPs { + detailsFilename := fmt.Sprintf("%s/details-%d.csv", outputFilePath, report.ID) + detailsFile, err := os.Create(detailsFilename) + if err != nil { + return fmt.Errorf("failed to create details CSV file %s: %v", detailsFilename, err) + } + defer detailsFile.Close() + + detailsWriter := csv.NewWriter(detailsFile) + defer detailsWriter.Flush() + + // Collect all IP detail rows + var detailRows [][]string + + // Header + detailRows = append(detailRows, []string{ + "IP", "Country", "AS Name", "Reputation", "Confidence", + "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", + }) + + // IP data + for _, ipItem := range report.IPs { + country := "N/A" + ipRange := "N/A" + asName := "N/A" + reverseDNS := "N/A" + + if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { + reverseDNS = *ipItem.ReverseDNS + } + if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { + country = *ipItem.Location.Country + } + if ipItem.IpRange != nil && *ipItem.IpRange != "" { + ipRange = *ipItem.IpRange + } + if ipItem.AsName != nil && *ipItem.AsName != "" { + asName = *ipItem.AsName + } + + behaviors := "" + for i, behavior := range ipItem.Behaviors { + if i > 0 { + behaviors += ", " + } + behaviors += behavior.Label + } + if behaviors == "" { + behaviors = "N/A" + } + + classif := "N/A" + if len(ipItem.Classifications.Classifications) > 0 { + for _, classification := range ipItem.Classifications.Classifications { + if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { + continue + } + classif = classification.Label + } + } + if len(ipItem.Classifications.FalsePositives) > 0 { + for _, classification := range ipItem.Classifications.FalsePositives { + classif = classification.Label + } + } + + firstSeen := "N/A" + lastSeen := "N/A" + if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { + firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] + } + if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { + lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] + } + + reputation := ipItem.Reputation + confidence := ipItem.Confidence + if reputation == "" { + reputation = "N/A" + confidence = "N/A" + } + + detailRows = append(detailRows, []string{ + ipItem.Ip, country, asName, reputation, confidence, + reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, + }) + } + + // Write all detail rows at once + for _, row := range detailRows { + if err := detailsWriter.Write(row); err != nil { + return fmt.Errorf("failed to write detail CSV row: %v", err) + } + } + + fmt.Printf("IP details saved to: %s\n", detailsFilename) + } + + return nil +} + +func saveIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { + filename := fmt.Sprintf("ip.%s.csv", item.Ip) + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create IP CSV file %s: %v", filename, err) + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + // Write the header + if err := writer.Write([]string{ + "IP", + "Reputation", + "Confidence", + "Country", + "Autonomous System", + "Reverse DNS", + "Range", + "First Seen", + "Last Seen", + "Console URL", + "Last Local Refresh", + "Behaviors", + "False Positives", + "Classifications", + "Blocklists", + "CVEs", + }); err != nil { + return err + } + + asName := "N/A" + if item.AsName != nil { + asName = *item.AsName + } + ipRange := "N/A" + if item.IpRange != nil { + ipRange = *item.IpRange + } + + reverseDNS := "N/A" + if item.ReverseDNS != nil { + reverseDNS = *item.ReverseDNS + } + + country := "N/A" + if item.Location.Country != nil { + country = *item.Location.Country + } + + reputationStr := item.Reputation + if item.Reputation == "safe" { + fps := "" + for i, fp := range item.Classifications.FalsePositives { + if i > 0 { + fps += ", " + } + fps += fp.Label + } + if fps != "" { + reputationStr += fmt.Sprintf(" (%s)", fps) + } + } + + firstSeen := "N/A" + lastSeen := "N/A" + if item.History.FirstSeen != nil && *item.History.FirstSeen != "" { + firstSeen = strings.Split(*item.History.FirstSeen, "+")[0] + } + if item.History.LastSeen != nil && *item.History.LastSeen != "" { + lastSeen = strings.Split(*item.History.LastSeen, "+")[0] + } + + // Collect behaviors + behaviors := "" + for i, behavior := range item.Behaviors { + if i > 0 { + behaviors += ", " + } + behaviors += behavior.Label + } + if behaviors == "" { + behaviors = "N/A" + } + + // Collect false positives + falsePositives := "" + for i, fp := range item.Classifications.FalsePositives { + if i > 0 { + falsePositives += ", " + } + falsePositives += fp.Label + } + if falsePositives == "" { + falsePositives = "N/A" + } + + // Collect classifications + classifications := "" + for i, classification := range item.Classifications.Classifications { + if i > 0 { + classifications += ", " + } + classifications += classification.Label + } + if classifications == "" { + classifications = "N/A" + } + + // Collect blocklists + blocklists := "" + for i, blocklist := range item.References { + if i > 0 { + blocklists += ", " + } + blocklists += blocklist.Label + } + if blocklists == "" { + blocklists = "N/A" + } + + // Collect CVEs + cves := "" + for i, cve := range item.CVEs { + if i > 0 { + cves += ", " + } + cves += cve + } + if cves == "" { + cves = "N/A" + } + + // Write the data + if err := writer.Write([]string{ + item.Ip, + reputationStr, + item.Confidence, + country, + asName, + reverseDNS, + ipRange, + firstSeen, + lastSeen, + fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), + ipLastRefresh.Format("2006-01-02 15:04:05"), + behaviors, + falsePositives, + classifications, + blocklists, + cves, + }); err != nil { + return err + } + + fmt.Printf("IP details saved to: %s\n", filename) + return nil +} + +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) + reportFile, err := os.Create(reportFilename) + if err != nil { + return fmt.Errorf("failed to create report JSON file %s: %v", reportFilename, err) + } + defer reportFile.Close() + + // Create a combined structure with report and stats + type ReportOutput struct { + Report *models.Report `json:"report"` + Stats *models.ReportStats `json:"stats"` + } + + output := ReportOutput{ + Report: report, + Stats: stats, + } + + encoder := json.NewEncoder(reportFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(output); err != nil { + return fmt.Errorf("failed to write JSON: %v", err) + } + + fmt.Printf("Report summary saved to: %s\n", reportFilename) + + // If detailed IP information is requested, save to a separate file + if withIPs { + detailsFilename := fmt.Sprintf("%s/details-%d.json", outputFilePath, report.ID) + detailsFile, err := os.Create(detailsFilename) + if err != nil { + return fmt.Errorf("failed to create details JSON file %s: %v", detailsFilename, err) + } + defer detailsFile.Close() + + encoder := json.NewEncoder(detailsFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(report.IPs); err != nil { + return fmt.Errorf("failed to write detail JSON: %v", err) + } + + fmt.Printf("IP details saved to: %s\n", detailsFilename) + } + + return nil +} + +func saveReportHuman(report *models.Report, stats *models.ReportStats, withIPs bool, outputFilePath string) error { + // Save the report summary + reportFilename := fmt.Sprintf("%s/report-%d.txt", outputFilePath, report.ID) + reportFile, err := os.Create(reportFilename) + if err != nil { + return fmt.Errorf("failed to create report text file %s: %v", reportFilename, err) + } + defer reportFile.Close() + + // Use a tabwriter for consistent formatting + writer := tabwriter.NewWriter(reportFile, 0, 8, 1, '\t', tabwriter.AlignRight) + + // General section + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + fmt.Fprintln(writer, "General") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + fmt.Fprintf(writer, "Report ID:\t%d\n", report.ID) + fmt.Fprintf(writer, "Report Name:\t%s\n", report.Name) + fmt.Fprintf(writer, "Creation Date:\t%s\n", report.CreatedAt.Format("2006-01-02 15:04:05")) + + if report.IsFile { + fmt.Fprintf(writer, "File path:\t%s\n", report.FilePath) + fmt.Fprintf(writer, "SHA256:\t%s\n", report.FileHash) + } + + if report.IsQuery { + fmt.Fprintf(writer, "Query:\t%s\n", report.Query) + fmt.Fprintf(writer, "Since Duration:\t%s\n", report.Since) + fmt.Fprintf(writer, "Since Time:\t%s\n", report.SinceTime.Format("2006-01-02 15:04:05")) + } + + fmt.Fprintf(writer, "Number of IPs:\t%d\n", len(report.IPs)) + + knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 + ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 + + fmt.Fprintf(writer, "Number of known IPs:\t%d (%.0f%%)\n", stats.NbIPs-stats.NbUnknownIPs, knownIPPercent) + fmt.Fprintf(writer, "Number of IPs in Blocklist:\t%d (%.0f%%)\n", stats.IPsBlockedByBlocklist, ipsInBlocklistPercent) + + // Stats section + fmt.Fprintln(writer, "") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + fmt.Fprintln(writer, "Stats") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + + // Top Reputation + TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) + if len(TopReputation) > 0 { + fmt.Fprintln(writer, "Top Reputation:") + for _, stat := range TopReputation { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", cases.Title(language.Und).String(stat.Key), stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top Classifications + topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) + if len(topClassification) > 0 { + fmt.Fprintln(writer, "Top Classifications:") + for _, stat := range topClassification { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top Behaviors + topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) + if len(topBehaviors) > 0 { + fmt.Fprintln(writer, "Top Behaviors:") + for _, stat := range topBehaviors { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top Blocklists + topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) + if len(topBlocklists) > 0 { + fmt.Fprintln(writer, "Top Blocklists:") + for _, stat := range topBlocklists { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top CVEs + topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) + if len(topCVEs) > 0 { + fmt.Fprintln(writer, "Top CVEs:") + for _, stat := range topCVEs { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top IP Ranges + TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) + if len(TopIPRange) > 0 { + fmt.Fprintln(writer, "Top IP Ranges:") + for _, stat := range TopIPRange { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top Autonomous Systems + topAS := getTopN(stats.TopAS, maxTopDisplayReport) + if len(topAS) > 0 { + fmt.Fprintln(writer, "Top Autonomous Systems:") + for _, stat := range topAS { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + // Top Countries + topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) + if len(topCountry) > 0 { + fmt.Fprintln(writer, "Top Countries:") + for _, stat := range topCountry { + percent := float64(stat.Value) / float64(stats.NbIPs) * 100 + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + } + fmt.Fprintln(writer, "") + } + + writer.Flush() + fmt.Printf("Report summary saved to: %s\n", reportFilename) + + // If detailed IP information is requested, save to a separate file + if withIPs { + detailsFilename := fmt.Sprintf("%s/details-%d.txt", outputFilePath, report.ID) + detailsFile, err := os.Create(detailsFilename) + if err != nil { + return fmt.Errorf("failed to create details text file %s: %v", detailsFilename, err) + } + defer detailsFile.Close() + + detailsWriter := tabwriter.NewWriter(detailsFile, 0, 8, 2, ' ', 0) + + // Header + fmt.Fprintln(detailsWriter, "IP\tCountry\tAS Name\tReputation\tConfidence\tReverse DNS\tProfile\tBehaviors\tRange\tFirst Seen\tLast Seen") + fmt.Fprintln(detailsWriter, "─────────────────\t──────────\t─────────────────────────\t──────────\t──────────\t─────────────────────────\t─────────────────────────\t─────────────────────────\t─────────────────\t─────────────────────\t─────────────────────") + + for _, ipItem := range report.IPs { + country := "N/A" + ipRange := "N/A" + asName := "N/A" + reverseDNS := "N/A" + + if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { + reverseDNS = *ipItem.ReverseDNS + } + if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { + country = *ipItem.Location.Country + } + if ipItem.IpRange != nil && *ipItem.IpRange != "" { + ipRange = *ipItem.IpRange + } + if ipItem.AsName != nil && *ipItem.AsName != "" { + asName = *ipItem.AsName + } + + behaviors := "" + for i, behavior := range ipItem.Behaviors { + if i > 0 { + behaviors += ", " + } + behaviors += behavior.Label + } + if behaviors == "" { + behaviors = "N/A" + } + + classif := "N/A" + if len(ipItem.Classifications.Classifications) > 0 { + for _, classification := range ipItem.Classifications.Classifications { + if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { + continue + } + classif = classification.Label + } + } + if len(ipItem.Classifications.FalsePositives) > 0 { + for _, classification := range ipItem.Classifications.FalsePositives { + classif = classification.Label + } + } + + firstSeen := "N/A" + lastSeen := "N/A" + if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { + firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] + } + if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { + lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] + } + + reputation := ipItem.Reputation + confidence := ipItem.Confidence + if reputation == "" { + reputation = "N/A" + confidence = "N/A" + } + + fmt.Fprintf(detailsWriter, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + ipItem.Ip, country, asName, reputation, confidence, + reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, + ) + } + + detailsWriter.Flush() + fmt.Printf("IP details saved to: %s\n", detailsFilename) + } + + return nil +} diff --git a/pkg/display/formats.go b/pkg/display/formats.go new file mode 100644 index 0000000..9cde7d4 --- /dev/null +++ b/pkg/display/formats.go @@ -0,0 +1,298 @@ +package display + +import ( + "fmt" + "strings" + + "github.com/crowdsecurity/crowdsec/pkg/cticlient" +) + +const ( + FormatHuman = "human" + FormatCSV = "csv" +) + +// Helper function to safely get string value or default to "N/A" +func strOrNA(ptr *string) string { + if ptr != nil && *ptr != "" { + return *ptr + } + return "N/A" +} + +// Format is a generic formatting function that takes any CTI type and formats it +// according to the specified format (human or csv). Defaults to human format. +func Format(data interface{}, format ...string) string { + // Default to human format if not specified + outputFormat := FormatHuman + if len(format) > 0 && format[0] != "" { + outputFormat = format[0] + } + + // Type switch to determine which format function to use + switch v := data.(type) { + case *cticlient.CTIBehavior: + return FormatCTIBehavior(v, outputFormat) + case []*cticlient.CTIBehavior: + return FormatCTIBehaviors(v, outputFormat) + case cticlient.CTIClassification: + return FormatCTIClassification(v, outputFormat) + case []cticlient.CTIClassification: + return FormatCTIClassifications(v, outputFormat) + case cticlient.CTIReferences: + return FormatCTIReference(v, outputFormat) + case []cticlient.CTIReferences: + return FormatCTIReferences(v, outputFormat) + case *cticlient.CTIAttackDetails: + return FormatCTIAttackDetails(v, outputFormat) + case []*cticlient.CTIAttackDetails: + return FormatCTIAttackDetailsSlice(v, outputFormat) + case cticlient.CTIHistory: + return FormatCTIHistory(v, outputFormat) + case cticlient.CTILocationInfo: + return FormatCTILocationInfo(v, outputFormat) + case cticlient.CTIScore: + return FormatCTIScore(v, outputFormat) + case cticlient.CTIScores: + return FormatCTIScores(v, outputFormat) + case []string: + return FormatCVEs(v, outputFormat) + case map[string]int: + return FormatTargetCountries(v, outputFormat) + case *string: + return strOrNA(v) + case string: + if v == "" { + return "N/A" + } + return v + case nil: + return "N/A" + default: + // Fallback to string representation + return fmt.Sprintf("%v", v) + } +} + +// FormatCTIBehavior formats a CTIBehavior for display +func FormatCTIBehavior(b *cticlient.CTIBehavior, format string) string { + if b == nil { + return "N/A" + } + + switch format { + case FormatCSV: + return b.Label + default: + return b.Label + } +} + +// FormatCTIBehaviors formats multiple CTIBehaviors for display +func FormatCTIBehaviors(behaviors []*cticlient.CTIBehavior, format string) string { + if len(behaviors) == 0 { + return "N/A" + } + + labels := make([]string, len(behaviors)) + for i, b := range behaviors { + labels[i] = FormatCTIBehavior(b, format) + } + + switch format { + case FormatCSV: + return strings.Join(labels, ", ") + default: + return strings.Join(labels, ", ") + } +} + +// FormatCTIClassification formats a CTIClassification for display +func FormatCTIClassification(c cticlient.CTIClassification, format string) string { + switch format { + case FormatCSV: + return c.Label + default: + return c.Label + } +} + +// FormatCTIClassifications formats multiple CTIClassifications for display +func FormatCTIClassifications(classifications []cticlient.CTIClassification, format string) string { + if len(classifications) == 0 { + return "N/A" + } + + labels := make([]string, len(classifications)) + for i, c := range classifications { + labels[i] = FormatCTIClassification(c, format) + } + + switch format { + case FormatCSV: + return strings.Join(labels, ", ") + default: + return strings.Join(labels, ", ") + } +} + +// FormatCTIReference formats a CTIReferences for display +func FormatCTIReference(r cticlient.CTIReferences, format string) string { + switch format { + case FormatCSV: + return r.Label + default: + return r.Label + } +} + +// FormatCTIReferences formats multiple CTIReferences for display +func FormatCTIReferences(references []cticlient.CTIReferences, format string) string { + if len(references) == 0 { + return "N/A" + } + + labels := make([]string, len(references)) + for i, r := range references { + labels[i] = FormatCTIReference(r, format) + } + + switch format { + case FormatCSV: + return strings.Join(labels, ", ") + default: + return strings.Join(labels, ", ") + } +} + +// FormatCTIAttackDetails formats a CTIAttackDetails for display +func FormatCTIAttackDetails(ad *cticlient.CTIAttackDetails, format string) string { + if ad == nil { + return "N/A" + } + + switch format { + case FormatCSV: + return ad.Label + default: + return fmt.Sprintf("%s: %s", ad.Label, ad.Description) + } +} + +// FormatCTIAttackDetailsSlice formats multiple CTIAttackDetails for display +func FormatCTIAttackDetailsSlice(attackDetails []*cticlient.CTIAttackDetails, format string) string { + if len(attackDetails) == 0 { + return "N/A" + } + + labels := make([]string, len(attackDetails)) + for i, ad := range attackDetails { + labels[i] = FormatCTIAttackDetails(ad, format) + } + + switch format { + case FormatCSV: + return strings.Join(labels, ", ") + default: + return strings.Join(labels, "\n") + } +} + +// FormatCTIHistory formats a CTIHistory for display +func FormatCTIHistory(h cticlient.CTIHistory, format string) string { + firstSeen := strOrNA(h.FirstSeen) + lastSeen := strOrNA(h.LastSeen) + + // Remove timezone info if present + if firstSeen != "N/A" { + firstSeen = strings.Split(firstSeen, "+")[0] + } + if lastSeen != "N/A" { + lastSeen = strings.Split(lastSeen, "+")[0] + } + + switch format { + case FormatCSV: + return fmt.Sprintf("%s,%s", firstSeen, lastSeen) + default: + return fmt.Sprintf("First seen: %s, Last seen: %s (Age: %d days)", firstSeen, lastSeen, h.DaysAge) + } +} + +// FormatCTILocationInfo formats a CTILocationInfo for display +func FormatCTILocationInfo(loc cticlient.CTILocationInfo, format string) string { + country := strOrNA(loc.Country) + city := strOrNA(loc.City) + + switch format { + case FormatCSV: + if city != "N/A" { + return fmt.Sprintf("%s, %s", city, country) + } + return country + default: + if city != "N/A" { + return fmt.Sprintf("%s, %s", city, country) + } + return country + } +} + +// FormatCTIScore formats a CTIScore for display +func FormatCTIScore(score cticlient.CTIScore, format string) string { + switch format { + case FormatCSV: + return fmt.Sprintf("%d", score.Total) + default: + return fmt.Sprintf("Total: %d (Aggressiveness: %d, Threat: %d, Trust: %d, Anomaly: %d)", + score.Total, score.Aggressiveness, score.Threat, score.Trust, score.Anomaly) + } +} + +// FormatCTIScores formats a CTIScores for display +func FormatCTIScores(scores cticlient.CTIScores, format string) string { + switch format { + case FormatCSV: + return fmt.Sprintf("%d,%d,%d,%d", + scores.Overall.Total, scores.LastDay.Total, scores.LastWeek.Total, scores.LastMonth.Total) + default: + return fmt.Sprintf("Overall: %d, Last Day: %d, Last Week: %d, Last Month: %d", + scores.Overall.Total, scores.LastDay.Total, scores.LastWeek.Total, scores.LastMonth.Total) + } +} + +// FormatCVEs formats CVEs for display +func FormatCVEs(cves []string, format string) string { + if len(cves) == 0 { + return "N/A" + } + + switch format { + case FormatCSV: + return strings.Join(cves, ", ") + default: + return strings.Join(cves, ", ") + } +} + +// FormatTargetCountries formats target countries map for display +func FormatTargetCountries(countries map[string]int, format string) string { + if len(countries) == 0 { + return "N/A" + } + + switch format { + case FormatCSV: + parts := make([]string, 0, len(countries)) + for country, count := range countries { + parts = append(parts, fmt.Sprintf("%s:%d", country, count)) + } + return strings.Join(parts, "; ") + default: + parts := make([]string, 0, len(countries)) + for country, count := range countries { + parts = append(parts, fmt.Sprintf("%s (%d%%)", country, count)) + } + return strings.Join(parts, ", ") + } +} 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 { From 995a2f5b13e6d5681467293b86ca86cd4f356778 Mon Sep 17 00:00:00 2001 From: jdv Date: Mon, 12 Jan 2026 22:05:17 +0100 Subject: [PATCH 4/7] linting --- pkg/display/display.go | 133 +++++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 53 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index e1be8b5..2c634a2 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -98,18 +98,20 @@ func displayIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { timestamps := strings.Split(history, ",") firstSeen, lastSeen := timestamps[0], timestamps[1] - w.Write([]string{"IP", "Reputation", "Confidence", "Country", "Autonomous System", + err := w.Write([]string{"IP", "Reputation", "Confidence", "Country", "Autonomous System", "Reverse DNS", "Range", "First Seen", "Last Seen", "Console URL", "Last Local Refresh", "Behaviors", "False Positives", "Classifications", "Blocklists", "CVEs"}) - w.Write([]string{item.Ip, reputation, item.Confidence, Format(item.Location, FormatCSV), - Format(item.AsName, FormatCSV), Format(item.ReverseDNS, FormatCSV), Format(item.IpRange, FormatCSV), - firstSeen, lastSeen, fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), - ipLastRefresh.Format("2006-01-02 15:04:05"), Format(item.Behaviors, FormatCSV), - Format(item.Classifications.FalsePositives, FormatCSV), Format(item.Classifications.Classifications, FormatCSV), - Format(item.References, FormatCSV), Format(item.CVEs, FormatCSV)}) + if err == nil { + err = w.Write([]string{item.Ip, reputation, item.Confidence, Format(item.Location, FormatCSV), + Format(item.AsName, FormatCSV), Format(item.ReverseDNS, FormatCSV), Format(item.IpRange, FormatCSV), + firstSeen, lastSeen, fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), + ipLastRefresh.Format("2006-01-02 15:04:05"), Format(item.Behaviors, FormatCSV), + Format(item.Classifications.FalsePositives, FormatCSV), Format(item.Classifications.Classifications, FormatCSV), + Format(item.References, FormatCSV), Format(item.CVEs, FormatCSV)}) + } - return nil + return err } func displayIPHuman(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error { @@ -607,132 +609,151 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo writer := csv.NewWriter(os.Stdout) defer writer.Flush() - // Write general section - writer.Write([]string{"General", "", ""}) - writer.Write([]string{"", "", ""}) - writer.Write([]string{"Report ID", strconv.Itoa(int(item.ID)), ""}) - writer.Write([]string{"Report Name", item.Name, ""}) - writer.Write([]string{"Creation Date", item.CreatedAt.Format("2006-01-02 15:04:05"), ""}) + // Helper function to write multiple rows + writeRows := func(rows [][]string) error { + for _, row := range rows { + if err := writer.Write(row); err != nil { + return err + } + } + return nil + } + + // Collect all rows to write + var rows [][]string + + // General section + rows = append(rows, []string{"General", "", ""}) + rows = append(rows, []string{"", "", ""}) + rows = append(rows, []string{"Report ID", strconv.Itoa(int(item.ID)), ""}) + rows = append(rows, []string{"Report Name", item.Name, ""}) + rows = append(rows, []string{"Creation Date", item.CreatedAt.Format("2006-01-02 15:04:05"), ""}) if item.IsFile { - writer.Write([]string{"File path", item.FilePath, ""}) - writer.Write([]string{"SHA256", item.FileHash, ""}) + rows = append(rows, []string{"File path", item.FilePath, ""}) + rows = append(rows, []string{"SHA256", item.FileHash, ""}) } if item.IsQuery { - writer.Write([]string{"Query", item.Query, ""}) - writer.Write([]string{"Since Duration", item.Since, ""}) - writer.Write([]string{"Since Time", item.SinceTime.Format("2006-01-02 15:04:05"), ""}) + rows = append(rows, []string{"Query", item.Query, ""}) + rows = append(rows, []string{"Since Duration", item.Since, ""}) + rows = append(rows, []string{"Since Time", item.SinceTime.Format("2006-01-02 15:04:05"), ""}) } - writer.Write([]string{"Number of IPs", strconv.Itoa(len(item.IPs)), ""}) + rows = append(rows, []string{"Number of IPs", strconv.Itoa(len(item.IPs)), ""}) knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 - writer.Write([]string{"Number of known IPs", fmt.Sprintf("%d", stats.NbIPs-stats.NbUnknownIPs), fmt.Sprintf("%.0f%%", knownIPPercent)}) - writer.Write([]string{"Number of IPs in Blocklist", fmt.Sprintf("%d", stats.IPsBlockedByBlocklist), fmt.Sprintf("%.0f%%", ipsInBlocklistPercent)}) + rows = append(rows, []string{"Number of known IPs", fmt.Sprintf("%d", stats.NbIPs-stats.NbUnknownIPs), fmt.Sprintf("%.0f%%", knownIPPercent)}) + rows = append(rows, []string{"Number of IPs in Blocklist", fmt.Sprintf("%d", stats.IPsBlockedByBlocklist), fmt.Sprintf("%.0f%%", ipsInBlocklistPercent)}) // Empty line before Stats section - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) // Stats section - writer.Write([]string{"Stats", "", ""}) - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"Stats", "", ""}) + rows = append(rows, []string{"", "", ""}) // Top Reputation TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) if len(TopReputation) > 0 { - writer.Write([]string{"Top Reputation", "", ""}) + rows = append(rows, []string{"Top Reputation", "", ""}) for _, stat := range TopReputation { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top Classifications topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) if len(topClassification) > 0 { - writer.Write([]string{"Top Classifications", "", ""}) + rows = append(rows, []string{"Top Classifications", "", ""}) for _, stat := range topClassification { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top Behaviors topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) if len(topBehaviors) > 0 { - writer.Write([]string{"Top Behaviors", "", ""}) + rows = append(rows, []string{"Top Behaviors", "", ""}) for _, stat := range topBehaviors { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top Blocklists topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) if len(topBlocklists) > 0 { - writer.Write([]string{"Top Blocklists", "", ""}) + rows = append(rows, []string{"Top Blocklists", "", ""}) for _, stat := range topBlocklists { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top CVEs topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) if len(topCVEs) > 0 { - writer.Write([]string{"Top CVEs", "", ""}) + rows = append(rows, []string{"Top CVEs", "", ""}) for _, stat := range topCVEs { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top IP Ranges TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) if len(TopIPRange) > 0 { - writer.Write([]string{"Top IP Ranges", "", ""}) + rows = append(rows, []string{"Top IP Ranges", "", ""}) for _, stat := range TopIPRange { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top Autonomous Systems topAS := getTopN(stats.TopAS, maxTopDisplayReport) if len(topAS) > 0 { - writer.Write([]string{"Top Autonomous Systems", "", ""}) + rows = append(rows, []string{"Top Autonomous Systems", "", ""}) for _, stat := range topAS { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) } // Top Countries topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) if len(topCountry) > 0 { - writer.Write([]string{"Top Countries", "", ""}) + rows = append(rows, []string{"Top Countries", "", ""}) for _, stat := range topCountry { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - writer.Write([]string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) + rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) } - writer.Write([]string{"", "", ""}) + rows = append(rows, []string{"", "", ""}) + } + + //write report IPs details + err := writeRows(rows) + if err != nil { + return err } // If detailed IP information is requested, show it if withIPs { - writer.Write([]string{"", "", ""}) - writer.Write([]string{"IP Details", "", ""}) - writer.Write([]string{ + rows = append(rows, []string{"", "", ""}) + rows = append(rows, []string{"IP Details", "", ""}) + rows = append(rows, []string{ "IP", "Country", "AS Name", "Reputation", "Confidence", "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", }) @@ -798,13 +819,19 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo confidence = "N/A" } - writer.Write([]string{ + rows = append(rows, []string{ ipItem.Ip, country, asName, reputation, confidence, reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, }) } } + // Write IP details rows + err = writeRows(rows) + if err != nil { + return err + } + // Write all rows at once return nil } From e3bae98c9725efe88eb0922dad754736ad6c07af Mon Sep 17 00:00:00 2001 From: jdv Date: Mon, 12 Jan 2026 22:11:01 +0100 Subject: [PATCH 5/7] removed unused function --- pkg/display/display.go | 161 ----------------------------------------- 1 file changed, 161 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 2c634a2..8a703d7 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -1092,167 +1092,6 @@ func saveReportCSV(report *models.Report, stats *models.ReportStats, withIPs boo return nil } -func saveIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { - filename := fmt.Sprintf("ip.%s.csv", item.Ip) - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create IP CSV file %s: %v", filename, err) - } - defer file.Close() - - writer := csv.NewWriter(file) - defer writer.Flush() - - // Write the header - if err := writer.Write([]string{ - "IP", - "Reputation", - "Confidence", - "Country", - "Autonomous System", - "Reverse DNS", - "Range", - "First Seen", - "Last Seen", - "Console URL", - "Last Local Refresh", - "Behaviors", - "False Positives", - "Classifications", - "Blocklists", - "CVEs", - }); err != nil { - return err - } - - asName := "N/A" - if item.AsName != nil { - asName = *item.AsName - } - ipRange := "N/A" - if item.IpRange != nil { - ipRange = *item.IpRange - } - - reverseDNS := "N/A" - if item.ReverseDNS != nil { - reverseDNS = *item.ReverseDNS - } - - country := "N/A" - if item.Location.Country != nil { - country = *item.Location.Country - } - - reputationStr := item.Reputation - if item.Reputation == "safe" { - fps := "" - for i, fp := range item.Classifications.FalsePositives { - if i > 0 { - fps += ", " - } - fps += fp.Label - } - if fps != "" { - reputationStr += fmt.Sprintf(" (%s)", fps) - } - } - - firstSeen := "N/A" - lastSeen := "N/A" - if item.History.FirstSeen != nil && *item.History.FirstSeen != "" { - firstSeen = strings.Split(*item.History.FirstSeen, "+")[0] - } - if item.History.LastSeen != nil && *item.History.LastSeen != "" { - lastSeen = strings.Split(*item.History.LastSeen, "+")[0] - } - - // Collect behaviors - behaviors := "" - for i, behavior := range item.Behaviors { - if i > 0 { - behaviors += ", " - } - behaviors += behavior.Label - } - if behaviors == "" { - behaviors = "N/A" - } - - // Collect false positives - falsePositives := "" - for i, fp := range item.Classifications.FalsePositives { - if i > 0 { - falsePositives += ", " - } - falsePositives += fp.Label - } - if falsePositives == "" { - falsePositives = "N/A" - } - - // Collect classifications - classifications := "" - for i, classification := range item.Classifications.Classifications { - if i > 0 { - classifications += ", " - } - classifications += classification.Label - } - if classifications == "" { - classifications = "N/A" - } - - // Collect blocklists - blocklists := "" - for i, blocklist := range item.References { - if i > 0 { - blocklists += ", " - } - blocklists += blocklist.Label - } - if blocklists == "" { - blocklists = "N/A" - } - - // Collect CVEs - cves := "" - for i, cve := range item.CVEs { - if i > 0 { - cves += ", " - } - cves += cve - } - if cves == "" { - cves = "N/A" - } - - // Write the data - if err := writer.Write([]string{ - item.Ip, - reputationStr, - item.Confidence, - country, - asName, - reverseDNS, - ipRange, - firstSeen, - lastSeen, - fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), - ipLastRefresh.Format("2006-01-02 15:04:05"), - behaviors, - falsePositives, - classifications, - blocklists, - cves, - }); err != nil { - return err - } - - fmt.Printf("IP details saved to: %s\n", filename) - return nil -} - 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) From fe5e9dd026f5b1e748d787ca6b79c7f90fc2b787 Mon Sep 17 00:00:00 2001 From: jdv Date: Mon, 12 Jan 2026 23:39:53 +0100 Subject: [PATCH 6/7] factorized and reordered for more logical reding of the code --- pkg/display/display.go | 1188 +++++++++++++++------------------------- 1 file changed, 455 insertions(+), 733 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 8a703d7..d5f98ac 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -54,6 +54,8 @@ func NewRowDisplay(writer *tabwriter.Writer, maxSpace int) *RowDisplay { } } +/// Top level display strategy functions + func (d *Display) DisplayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, format string, detailed bool) error { switch format { case HumanFormat: @@ -74,46 +76,55 @@ func (d *Display) DisplayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, return nil } -func displayIPJSON(item *cticlient.SmokeItem) error { - jsonData, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Printf("%s\n", jsonData) - return nil -} - -func displayIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { - w := csv.NewWriter(os.Stdout) - defer w.Flush() - - // Build reputation with false positives if applicable - reputation := item.Reputation - if reputation == "safe" && len(item.Classifications.FalsePositives) > 0 { - reputation = fmt.Sprintf("%s (%s)", reputation, Format(item.Classifications.FalsePositives, FormatCSV)) - } +func (d *Display) DisplayReport(report *models.Report, stats *models.ReportStats, format string, withIPs bool, outputFilePath string) error { + switch format { + case HumanFormat: + humanFormattedData := buildHumanReportData(report, stats, withIPs) + if err := displayReportHuman(humanFormattedData); err != nil { + return err + } + if outputFilePath != "" { + if err := saveReportHuman(humanFormattedData, int(report.ID), outputFilePath); err != nil { + return err + } + } + case JSONFormat: + if err := displayReportJSON(report, stats); err != nil { + return err + } + if outputFilePath != "" { + if err := saveReportJSON(report, stats, withIPs, outputFilePath); err != nil { + return err + } + } + case CSVFormat: + csvReportRows := buildCSVReportRows(report, stats, withIPs, false) + csvDetailRows := [][]string{} + if err := displayCSVRows(csvReportRows); err != nil { + return err + } - // Extract timestamps - history := Format(item.History, FormatCSV) - timestamps := strings.Split(history, ",") - firstSeen, lastSeen := timestamps[0], timestamps[1] + if withIPs { + csvDetailRows = buildCSVDetailsRows(report) + if err := displayCSVRows(csvDetailRows); err != nil { + return err + } + } - err := w.Write([]string{"IP", "Reputation", "Confidence", "Country", "Autonomous System", - "Reverse DNS", "Range", "First Seen", "Last Seen", "Console URL", - "Last Local Refresh", "Behaviors", "False Positives", "Classifications", "Blocklists", "CVEs"}) - - if err == nil { - err = w.Write([]string{item.Ip, reputation, item.Confidence, Format(item.Location, FormatCSV), - Format(item.AsName, FormatCSV), Format(item.ReverseDNS, FormatCSV), Format(item.IpRange, FormatCSV), - firstSeen, lastSeen, fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip), - ipLastRefresh.Format("2006-01-02 15:04:05"), Format(item.Behaviors, FormatCSV), - Format(item.Classifications.FalsePositives, FormatCSV), Format(item.Classifications.Classifications, FormatCSV), - Format(item.References, FormatCSV), Format(item.CVEs, FormatCSV)}) + if outputFilePath != "" { + if err := saveReportCSV(csvReportRows, csvDetailRows, int(report.ID), outputFilePath); err != nil { + return err + } + } + default: + return fmt.Errorf("format '%s' not supported", format) } - return err + return nil } +/// IP display functions + func displayIPHuman(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error { keyStyle := lipgloss.NewStyle(). Bold(true). @@ -322,182 +333,183 @@ func displayIPHuman(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed return nil } -func (d *Display) DisplayReport(item *models.Report, stats *models.ReportStats, format string, withIPs bool, outputFilePath string) error { - switch format { - case HumanFormat: - if err := displayReportHuman(item, stats, withIPs); err != nil { - return err - } - case JSONFormat: - if err := displayReportJSON(item, stats); err != nil { - return err - } - case CSVFormat: - // For CSV format, display in CSV format on screen - if err := displayReportCSV(item, stats, withIPs); err != nil { - return err - } - default: - return fmt.Errorf("format '%s' not supported", format) +func displayIPJSON(item *cticlient.SmokeItem) error { + jsonData, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err } + fmt.Printf("%s\n", jsonData) + return nil +} - // If output file path is provided, save files in the specified format - if outputFilePath != "" { - switch format { - case HumanFormat: - if err := saveReportHuman(item, stats, withIPs, outputFilePath); err != nil { - return err - } - case JSONFormat: - if err := saveReportJSON(item, stats, withIPs, outputFilePath); err != nil { - return err - } - case CSVFormat: - if err := saveReportCSV(item, stats, withIPs, outputFilePath); err != nil { - return err - } - default: - return fmt.Errorf("format '%s' not supported for file output", format) - } +func displayIPCSV(item *cticlient.SmokeItem, ipLastRefresh time.Time) error { + w := csv.NewWriter(os.Stdout) + defer w.Flush() + + // Build reputation with false positives if applicable + reputation := item.Reputation + if reputation == "safe" && len(item.Classifications.FalsePositives) > 0 { + reputation = fmt.Sprintf("%s (%s)", reputation, Format(item.Classifications.FalsePositives, FormatCSV)) } - return nil + // Extract timestamps + history := Format(item.History, FormatCSV) + timestamps := strings.Split(history, ",") + firstSeen, lastSeen := timestamps[0], timestamps[1] + + // Define fields in order with header and data + type field struct { + header string + value string + } + + fieldsToDisplay := []field{ + {"IP", item.Ip}, + {"Reputation", reputation}, + {"Confidence", item.Confidence}, + {"Country", Format(item.Location, FormatCSV)}, + {"Autonomous System", Format(item.AsName, FormatCSV)}, + {"Reverse DNS", Format(item.ReverseDNS, FormatCSV)}, + {"Range", Format(item.IpRange, FormatCSV)}, + {"First Seen", firstSeen}, + {"Last Seen", lastSeen}, + {"Console URL", fmt.Sprintf("https://app.crowdsec.net/cti/%s", item.Ip)}, + {"Last Local Refresh", ipLastRefresh.Format("2006-01-02 15:04:05")}, + {"Behaviors", Format(item.Behaviors, FormatCSV)}, + {"False Positives", Format(item.Classifications.FalsePositives, FormatCSV)}, + {"Classifications", Format(item.Classifications.Classifications, FormatCSV)}, + {"Blocklists", Format(item.References, FormatCSV)}, + {"CVEs", Format(item.CVEs, FormatCSV)}, + } + + // Extract headers and values from fieldsToDisplay + var headers, values []string + for _, f := range fieldsToDisplay { + headers = append(headers, f.header) + values = append(values, f.value) + } + + // Write headers + if err := w.Write(headers); err != nil { + return err + } + + // Write data row + return w.Write(values) } -func displayReportHuman(report *models.Report, stats *models.ReportStats, withIPs bool) error { - keyStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("15")) +/// Report display functions - valueStyle := lipgloss.NewStyle().Bold(true) - writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', tabwriter.AlignRight) - sectionStyle := pterm.NewStyle(pterm.FgWhite, pterm.Bold) - rd := NewRowDisplay(writer, maxKeyLength) +// // HumanReportData holds structured report data for formatting +type HumanReportData struct { + General []KeyValue + TopSections []TopSection + IPTableData [][]string + Stats *models.ReportStats + KnownIPPercent float64 + IPsInBlocklistPercent float64 +} - PrintSection(sectionStyle, "General") - rd.PrintRow("Report ID", strconv.Itoa(int(report.ID)), keyStyle, valueStyle) - rd.PrintRow("Report Name", report.Name, keyStyle, valueStyle) - rd.PrintRow("Creation Date", report.CreatedAt.Format("2006-01-02 15:04:05"), keyStyle, valueStyle) - if report.IsFile { - rd.PrintRow("File path", report.FilePath, keyStyle, valueStyle) - rd.PrintRow("SHA256", report.FileHash, keyStyle, valueStyle) - } - if report.IsQuery { - rd.PrintRow("Query", report.Query, keyStyle, valueStyle) - rd.PrintRow("Since Duration", report.Since, keyStyle, valueStyle) - rd.PrintRow("Since Time", report.SinceTime.Format("2006-01-02 15:04:05"), keyStyle, valueStyle) - } - rd.PrintRow("Number of IPs", strconv.Itoa(len(report.IPs)), keyStyle, valueStyle) +type KeyValue struct { + Key string + Value string +} - knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 - ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 - rd.PrintRow("Number of known IPs", fmt.Sprintf("%d (%.0f%%)", stats.NbIPs-stats.NbUnknownIPs, knownIPPercent), keyStyle, GetPercentKnownColor(valueStyle, knownIPPercent)) - rd.PrintRow("Number of IPs in Blocklist", fmt.Sprintf("%d (%.0f%%)", stats.IPsBlockedByBlocklist, ipsInBlocklistPercent), keyStyle, GetPercentKnownColor(valueStyle, knownIPPercent)) - PrintSection(sectionStyle, "Stats") +type TopSection struct { + Title string + Emoji string + Items []TopItem +} - topWriter := tabwriter.NewWriter(os.Stdout, 0, 8, 10, '\t', tabwriter.AlignRight) - topRD := NewRowDisplay(topWriter, 50) +type TopItem struct { + Key string + Value int + Percent float64 +} - TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) - if len(TopReputation) > 0 { - rd.PrintRow("🌟 Top Reputation", "", keyStyle, valueStyle) - for _, stat := range TopReputation { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", cases.Title(language.Und).String((TruncateWithEllipsis(stat.Key, maxKeyLen)))), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, GetReputationStyle(valueStyle, stat.Key)) - } - topWriter.Flush() - } - fmt.Println() - // Top Classifications - topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) - if len(topClassification) > 0 { - rd.PrintRow("🗂️ Top Classifications", "", keyStyle, valueStyle) - for _, stat := range topClassification { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) - } - topWriter.Flush() +// buildHumanReportData extracts report data into a structured format (used by both display and save) +func buildHumanReportData(report *models.Report, stats *models.ReportStats, withIPs bool) *HumanReportData { + data := &HumanReportData{ + Stats: stats, } - fmt.Println() - // Top Behaviors - topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) - if len(topBehaviors) > 0 { - rd.PrintRow("🤖 Top Behaviors", "", keyStyle, valueStyle) - for _, stat := range topBehaviors { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) - } - topWriter.Flush() + // General section + data.General = []KeyValue{ + {"Report ID", strconv.Itoa(int(report.ID))}, + {"Report Name", report.Name}, + {"Creation Date", report.CreatedAt.Format("2006-01-02 15:04:05")}, } - fmt.Println() - topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) - if len(topBlocklists) > 0 { - rd.PrintRow("⛔ Top Blocklists", "", keyStyle, valueStyle) - for _, stat := range topBlocklists { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) - } - topWriter.Flush() + if report.IsFile { + data.General = append(data.General, + KeyValue{"File path", report.FilePath}, + KeyValue{"SHA256", report.FileHash}, + ) } - fmt.Println() - topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) - if len(topCVEs) > 0 { - rd.PrintRow("💥 Top CVEs", "", keyStyle, valueStyle) - for _, stat := range topCVEs { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) - } - topWriter.Flush() + if report.IsQuery { + data.General = append(data.General, + KeyValue{"Query", report.Query}, + KeyValue{"Since Duration", report.Since}, + KeyValue{"Since Time", report.SinceTime.Format("2006-01-02 15:04:05")}, + ) } - fmt.Println() - // Top IP Ranges - TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) - if len(TopIPRange) > 0 { - rd.PrintRow("🌐 Top IP Ranges", "", keyStyle, valueStyle) - for _, stat := range TopIPRange { + data.General = append(data.General, KeyValue{"Number of IPs", strconv.Itoa(len(report.IPs))}) + + data.KnownIPPercent = float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 + data.IPsInBlocklistPercent = float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 + + data.General = append(data.General, + KeyValue{"Number of known IPs", fmt.Sprintf("%d (%.0f%%)", stats.NbIPs-stats.NbUnknownIPs, data.KnownIPPercent)}, + KeyValue{"Number of IPs in Blocklist", fmt.Sprintf("%d (%.0f%%)", stats.IPsBlockedByBlocklist, data.IPsInBlocklistPercent)}, + ) + + // Build top sections + buildSection := func(title, emoji string, topStats []KV) *TopSection { + if len(topStats) == 0 { + return nil + } + section := &TopSection{Title: title, Emoji: emoji} + for _, stat := range topStats { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) + section.Items = append(section.Items, TopItem{ + Key: stat.Key, + Value: stat.Value, + Percent: percent, + }) } - topWriter.Flush() + return section } - fmt.Println() - // Top Autonomous Systems - topAS := getTopN(stats.TopAS, maxTopDisplayReport) - if len(topAS) > 0 { - rd.PrintRow("🛰️ Top Autonomous Systems", "", keyStyle, valueStyle) - for _, stat := range topAS { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s", TruncateWithEllipsis(stat.Key, maxKeyLen)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) - } - topWriter.Flush() + sections := []*TopSection{ + buildSection("Top Reputation", "🌟", getTopN(stats.TopReputation, maxTopDisplayReport)), + buildSection("Top Classifications", "🗂️", getTopN(stats.TopClassifications, maxTopDisplayReport)), + buildSection("Top Behaviors", "🤖", getTopN(stats.TopBehaviors, maxTopDisplayReport)), + buildSection("Top Blocklists", "⛔", getTopN(stats.TopBlocklists, maxTopDisplayReport)), + buildSection("Top CVEs", "💥", getTopN(stats.TopCVEs, maxTopDisplayReport)), + buildSection("Top IP Ranges", "🌐", getTopN(stats.TopIPRange, maxTopDisplayReport)), + buildSection("Top Autonomous Systems", "🛰️", getTopN(stats.TopAS, maxTopDisplayReport)), + buildSection("Top Countries", "🌎", getTopN(stats.TopCountries, maxTopDisplayReport)), } - fmt.Println() - // Top Countries - topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) - if len(topCountry) > 0 { - rd.PrintRow("🌎 Top Countries", "", keyStyle, valueStyle) - for _, stat := range topCountry { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - topRD.PrintRow(fmt.Sprintf("\t %s %s", TruncateWithEllipsis(stat.Key, maxKeyLen), getFlag(stat.Key)), fmt.Sprintf("%d (%.0f%%)", stat.Value, percent), keyStyle, valueStyle) + for _, section := range sections { + if section != nil { + data.TopSections = append(data.TopSections, *section) } - topWriter.Flush() } - fmt.Println() - maxLineLength := 25 + + // Build IP table data if requested if withIPs { - var tableData [][]string - tableData = append(tableData, []string{"IP", "Country", "AS Name", "Reputation", "Confidence", "Reverse DNS", "Profile", "Behaviors", "Range"}) + maxLineLength := 25 + data.IPTableData = [][]string{{"IP", "Country", "AS Name", "Reputation", "Confidence", "Reverse DNS", "Profile", "Behaviors", "Range"}} + for _, item := range report.IPs { country := "N/A" ipRange := "N/A" asName := "N/A" reverseDNS := "N/A" + if item.ReverseDNS != nil && *item.ReverseDNS != "" { reverseDNS = *item.ReverseDNS if len(reverseDNS) > maxLineLength { @@ -510,37 +522,33 @@ func displayReportHuman(report *models.Report, stats *models.ReportStats, withIP if item.IpRange != nil && *item.IpRange != "" { ipRange = *item.IpRange } - if item.IpRange != nil && *item.IpRange != "" { - ipRange = *item.IpRange - } if item.AsName != nil && *item.AsName != "" { asName = *item.AsName if len(asName) > maxLineLength { asName = asName[:maxLineLength] + "..." } } + behaviors := "" for i, behavior := range item.Behaviors { if len(behaviors)+len(behavior.Label) > maxLineLength { behaviors += "..." break } - - // Append the label if behaviors != "" { behaviors += ", " } behaviors += behavior.Label - if i+1 < len(item.Behaviors) && len(behaviors)+len(item.Behaviors[i+1].Label)+2 > maxLineLength { behaviors += "..." break } } + classif := "N/A" if len(item.Classifications.Classifications) > 0 { for _, classification := range item.Classifications.Classifications { - if len(item.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdSec community blocklist" { + if len(item.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { continue } classif = classification.Label @@ -553,35 +561,108 @@ func displayReportHuman(report *models.Report, stats *models.ReportStats, withIP } if item.Reputation == "" { - tableData = append(tableData, []string{ - item.Ip, - "N/A", - "N/A", - "N/A", - "N/A", - "N/A", - "N/A", - "N/A", - "N/A", + data.IPTableData = append(data.IPTableData, []string{ + item.Ip, "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A", }) continue } - reputationStyle := GetReputationStyle(valueStyle, item.Reputation) - tableData = append(tableData, []string{ + data.IPTableData = append(data.IPTableData, []string{ item.Ip, - getFlag(country) + " " + country, + country, asName, - reputationStyle.Render(item.Reputation), - GetLevelStyle(valueStyle, item.Confidence).Render(item.Confidence), + item.Reputation, + item.Confidence, reverseDNS, classif, behaviors, ipRange, }) } + } + + return data +} + +func displayReportHuman(data *HumanReportData) error { + keyStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + valueStyle := lipgloss.NewStyle().Bold(true) + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', tabwriter.AlignRight) + sectionStyle := pterm.NewStyle(pterm.FgWhite, pterm.Bold) + rd := NewRowDisplay(writer, maxKeyLength) + + // Display General section + PrintSection(sectionStyle, "General") + for i, kv := range data.General { + // Apply special styling for known IPs percentages + if i == len(data.General)-2 { + rd.PrintRow(kv.Key, kv.Value, keyStyle, GetPercentKnownColor(valueStyle, data.KnownIPPercent)) + } else if i == len(data.General)-1 { + rd.PrintRow(kv.Key, kv.Value, keyStyle, GetPercentKnownColor(valueStyle, data.KnownIPPercent)) + } else { + rd.PrintRow(kv.Key, kv.Value, keyStyle, valueStyle) + } + } + + PrintSection(sectionStyle, "Stats") + + topWriter := tabwriter.NewWriter(os.Stdout, 0, 8, 10, '\t', tabwriter.AlignRight) + topRD := NewRowDisplay(topWriter, 50) + + // Display top sections + for _, section := range data.TopSections { + rd.PrintRow(section.Emoji+" "+section.Title, "", keyStyle, valueStyle) + for _, item := range section.Items { + displayKey := fmt.Sprintf("\t %s", TruncateWithEllipsis(item.Key, maxKeyLen)) + displayValue := fmt.Sprintf("%d (%.0f%%)", item.Value, item.Percent) + + // Apply special styling for reputation + if section.Title == "Top Reputation" { + displayKey = fmt.Sprintf("\t %s", cases.Title(language.Und).String(TruncateWithEllipsis(item.Key, maxKeyLen))) + topRD.PrintRow(displayKey, displayValue, keyStyle, GetReputationStyle(valueStyle, item.Key)) + } else if section.Title == "Top Countries" { + displayKey = fmt.Sprintf("\t %s %s", TruncateWithEllipsis(item.Key, maxKeyLen), getFlag(item.Key)) + topRD.PrintRow(displayKey, displayValue, keyStyle, valueStyle) + } else { + topRD.PrintRow(displayKey, displayValue, keyStyle, valueStyle) + } + } + topWriter.Flush() fmt.Println() - if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { + } + + // Display IP table if available + if len(data.IPTableData) > 1 { + // Apply styling to the table data + var styledTableData [][]string + styledTableData = append(styledTableData, data.IPTableData[0]) // Header + + for i := 1; i < len(data.IPTableData); i++ { + row := data.IPTableData[i] + if len(row) < 9 { + styledTableData = append(styledTableData, row) + continue + } + + country := row[1] + reputation := row[3] + confidence := row[4] + + styledRow := []string{ + row[0], // IP + getFlag(country) + " " + country, + row[2], // AS Name + GetReputationStyle(valueStyle, reputation).Render(reputation), + GetLevelStyle(valueStyle, confidence).Render(confidence), + row[5], // Reverse DNS + row[6], // Profile + row[7], // Behaviors + row[8], // Range + } + styledTableData = append(styledTableData, styledRow) + } + + if err := pterm.DefaultTable.WithHasHeader().WithData(styledTableData).Render(); err != nil { style.Fatal(err.Error()) } } @@ -605,42 +686,28 @@ func displayReportJSON(item *models.Report, stats *models.ReportStats) error { return nil } -func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bool) error { - writer := csv.NewWriter(os.Stdout) - defer writer.Flush() - - // Helper function to write multiple rows - writeRows := func(rows [][]string) error { - for _, row := range rows { - if err := writer.Write(row); err != nil { - return err - } - } - return nil - } - - // Collect all rows to write +func buildCSVReportRows(report *models.Report, stats *models.ReportStats, withIPs bool, includeEmojis bool) [][]string { var rows [][]string // General section rows = append(rows, []string{"General", "", ""}) rows = append(rows, []string{"", "", ""}) - rows = append(rows, []string{"Report ID", strconv.Itoa(int(item.ID)), ""}) - rows = append(rows, []string{"Report Name", item.Name, ""}) - rows = append(rows, []string{"Creation Date", item.CreatedAt.Format("2006-01-02 15:04:05"), ""}) + rows = append(rows, []string{"Report ID", strconv.Itoa(int(report.ID)), ""}) + rows = append(rows, []string{"Report Name", report.Name, ""}) + rows = append(rows, []string{"Creation Date", report.CreatedAt.Format("2006-01-02 15:04:05"), ""}) - if item.IsFile { - rows = append(rows, []string{"File path", item.FilePath, ""}) - rows = append(rows, []string{"SHA256", item.FileHash, ""}) + if report.IsFile { + rows = append(rows, []string{"File path", report.FilePath, ""}) + rows = append(rows, []string{"SHA256", report.FileHash, ""}) } - if item.IsQuery { - rows = append(rows, []string{"Query", item.Query, ""}) - rows = append(rows, []string{"Since Duration", item.Since, ""}) - rows = append(rows, []string{"Since Time", item.SinceTime.Format("2006-01-02 15:04:05"), ""}) + if report.IsQuery { + rows = append(rows, []string{"Query", report.Query, ""}) + rows = append(rows, []string{"Since Duration", report.Since, ""}) + rows = append(rows, []string{"Since Time", report.SinceTime.Format("2006-01-02 15:04:05"), ""}) } - rows = append(rows, []string{"Number of IPs", strconv.Itoa(len(item.IPs)), ""}) + rows = append(rows, []string{"Number of IPs", strconv.Itoa(len(report.IPs)), ""}) knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 @@ -655,10 +722,18 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo rows = append(rows, []string{"Stats", "", ""}) rows = append(rows, []string{"", "", ""}) + // Helper function to format section titles with optional emojis + sectionTitle := func(emoji, title string) string { + if includeEmojis { + return emoji + " " + title + } + return title + } + // Top Reputation TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) if len(TopReputation) > 0 { - rows = append(rows, []string{"Top Reputation", "", ""}) + rows = append(rows, []string{sectionTitle("🌟", "Top Reputation"), "", ""}) for _, stat := range TopReputation { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -669,7 +744,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top Classifications topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) if len(topClassification) > 0 { - rows = append(rows, []string{"Top Classifications", "", ""}) + rows = append(rows, []string{sectionTitle("🗂️", "Top Classifications"), "", ""}) for _, stat := range topClassification { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -680,7 +755,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top Behaviors topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) if len(topBehaviors) > 0 { - rows = append(rows, []string{"Top Behaviors", "", ""}) + rows = append(rows, []string{sectionTitle("🤖", "Top Behaviors"), "", ""}) for _, stat := range topBehaviors { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -691,7 +766,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top Blocklists topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) if len(topBlocklists) > 0 { - rows = append(rows, []string{"Top Blocklists", "", ""}) + rows = append(rows, []string{sectionTitle("⛔", "Top Blocklists"), "", ""}) for _, stat := range topBlocklists { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -702,7 +777,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top CVEs topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) if len(topCVEs) > 0 { - rows = append(rows, []string{"Top CVEs", "", ""}) + rows = append(rows, []string{sectionTitle("💥", "Top CVEs"), "", ""}) for _, stat := range topCVEs { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -713,7 +788,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top IP Ranges TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) if len(TopIPRange) > 0 { - rows = append(rows, []string{"Top IP Ranges", "", ""}) + rows = append(rows, []string{sectionTitle("🌐", "Top IP Ranges"), "", ""}) for _, stat := range TopIPRange { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -724,7 +799,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top Autonomous Systems topAS := getTopN(stats.TopAS, maxTopDisplayReport) if len(topAS) > 0 { - rows = append(rows, []string{"Top Autonomous Systems", "", ""}) + rows = append(rows, []string{sectionTitle("🛰️", "Top Autonomous Systems"), "", ""}) for _, stat := range topAS { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -735,7 +810,7 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo // Top Countries topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) if len(topCountry) > 0 { - rows = append(rows, []string{"Top Countries", "", ""}) + rows = append(rows, []string{sectionTitle("🌎", "Top Countries"), "", ""}) for _, stat := range topCountry { percent := float64(stat.Value) / float64(stats.NbIPs) * 100 rows = append(rows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) @@ -743,349 +818,165 @@ func displayReportCSV(item *models.Report, stats *models.ReportStats, withIPs bo rows = append(rows, []string{"", "", ""}) } - //write report IPs details - err := writeRows(rows) - if err != nil { - return err - } + return rows +} - // If detailed IP information is requested, show it - if withIPs { - rows = append(rows, []string{"", "", ""}) - rows = append(rows, []string{"IP Details", "", ""}) - rows = append(rows, []string{ - "IP", "Country", "AS Name", "Reputation", "Confidence", - "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", - }) +func buildCSVDetailsRows(report *models.Report) [][]string { + var rows [][]string - for _, ipItem := range item.IPs { - country := "N/A" - ipRange := "N/A" - asName := "N/A" - reverseDNS := "N/A" + //Header row + rows = append(rows, []string{ + "IP", "Country", "AS Name", "Reputation", "Confidence", + "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", + }) - if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { - reverseDNS = *ipItem.ReverseDNS - } - if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { - country = *ipItem.Location.Country - } - if ipItem.IpRange != nil && *ipItem.IpRange != "" { - ipRange = *ipItem.IpRange - } - if ipItem.AsName != nil && *ipItem.AsName != "" { - asName = *ipItem.AsName - } + for _, ipItem := range report.IPs { + country := "N/A" + ipRange := "N/A" + asName := "N/A" + reverseDNS := "N/A" - behaviors := "" - for i, behavior := range ipItem.Behaviors { - if i > 0 { - behaviors += ", " - } - behaviors += behavior.Label - } - if behaviors == "" { - behaviors = "N/A" - } + if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { + reverseDNS = *ipItem.ReverseDNS + } + if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { + country = *ipItem.Location.Country + } + if ipItem.IpRange != nil && *ipItem.IpRange != "" { + ipRange = *ipItem.IpRange + } + if ipItem.AsName != nil && *ipItem.AsName != "" { + asName = *ipItem.AsName + } - classif := "N/A" - if len(ipItem.Classifications.Classifications) > 0 { - for _, classification := range ipItem.Classifications.Classifications { - if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { - continue - } - classif = classification.Label - } - } - if len(ipItem.Classifications.FalsePositives) > 0 { - for _, classification := range ipItem.Classifications.FalsePositives { - classif = classification.Label - } + behaviors := "" + for i, behavior := range ipItem.Behaviors { + if i > 0 { + behaviors += ", " } + behaviors += behavior.Label + } + if behaviors == "" { + behaviors = "N/A" + } - firstSeen := "N/A" - lastSeen := "N/A" - if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { - firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] + classif := "N/A" + if len(ipItem.Classifications.Classifications) > 0 { + for _, classification := range ipItem.Classifications.Classifications { + if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { + continue + } + classif = classification.Label } - if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { - lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] + } + if len(ipItem.Classifications.FalsePositives) > 0 { + for _, classification := range ipItem.Classifications.FalsePositives { + classif = classification.Label } + } - reputation := ipItem.Reputation - confidence := ipItem.Confidence - if reputation == "" { - reputation = "N/A" - confidence = "N/A" - } + firstSeen := "N/A" + lastSeen := "N/A" + if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { + firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] + } + if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { + lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] + } - rows = append(rows, []string{ - ipItem.Ip, country, asName, reputation, confidence, - reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, - }) + reputation := ipItem.Reputation + confidence := ipItem.Confidence + if reputation == "" { + reputation = "N/A" + confidence = "N/A" } - } - // Write IP details rows - err = writeRows(rows) - if err != nil { - return err + rows = append(rows, []string{ + ipItem.Ip, country, asName, reputation, confidence, + reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, + }) } - // Write all rows at once - return nil + + return rows } -func TruncateWithEllipsis(s string, max int) string { - if len(s) <= max { - return s - } - if max <= 3 { - return "..." +func displayCSVRows(rows [][]string) error { + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + for _, row := range rows { + if err := writer.Write(row); err != nil { + return err + } } - return s[:max-3] + "..." + + return nil } -func saveReportCSV(report *models.Report, stats *models.ReportStats, withIPs bool, outputFilePath string) error { - // Always save the report summary - reportFilename := fmt.Sprintf("%s/report-%d.csv", outputFilePath, report.ID) +func saveReportHuman(data *HumanReportData, reportID int, outputFilePath string) error { + // Save the report summary + reportFilename := fmt.Sprintf("%s/report-%d.txt", outputFilePath, reportID) reportFile, err := os.Create(reportFilename) if err != nil { - return fmt.Errorf("failed to create report CSV file %s: %v", reportFilename, err) + return fmt.Errorf("failed to create report text file %s: %v", reportFilename, err) } defer reportFile.Close() - reportWriter := csv.NewWriter(reportFile) - defer reportWriter.Flush() - - // Collect all CSV rows - var csvRows [][]string + writer := tabwriter.NewWriter(reportFile, 0, 8, 1, '\t', tabwriter.AlignRight) // General section - csvRows = append(csvRows, []string{"General", "", ""}) - csvRows = append(csvRows, []string{"", "", ""}) - csvRows = append(csvRows, []string{"Report ID", strconv.Itoa(int(report.ID)), ""}) - csvRows = append(csvRows, []string{"Report Name", report.Name, ""}) - csvRows = append(csvRows, []string{"Creation Date", report.CreatedAt.Format("2006-01-02 15:04:05"), ""}) - - if report.IsFile { - csvRows = append(csvRows, []string{"File path", report.FilePath, ""}) - csvRows = append(csvRows, []string{"SHA256", report.FileHash, ""}) - } - - if report.IsQuery { - csvRows = append(csvRows, []string{"Query", report.Query, ""}) - csvRows = append(csvRows, []string{"Since Duration", report.Since, ""}) - csvRows = append(csvRows, []string{"Since Time", report.SinceTime.Format("2006-01-02 15:04:05"), ""}) + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + fmt.Fprintln(writer, "General") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + for _, kv := range data.General { + fmt.Fprintf(writer, "%s:\t%s\n", kv.Key, kv.Value) } - csvRows = append(csvRows, []string{"Number of IPs", strconv.Itoa(len(report.IPs)), ""}) - - knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 - ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 - - csvRows = append(csvRows, []string{"Number of known IPs", fmt.Sprintf("%d", stats.NbIPs-stats.NbUnknownIPs), fmt.Sprintf("%.0f%%", knownIPPercent)}) - csvRows = append(csvRows, []string{"Number of IPs in Blocklist", fmt.Sprintf("%d", stats.IPsBlockedByBlocklist), fmt.Sprintf("%.0f%%", ipsInBlocklistPercent)}) - - // Empty line before Stats section - csvRows = append(csvRows, []string{"", "", ""}) - // Stats section - csvRows = append(csvRows, []string{"Stats", "", ""}) - csvRows = append(csvRows, []string{"", "", ""}) - - // Top Reputation - TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) - if len(TopReputation) > 0 { - csvRows = append(csvRows, []string{"🌟 Top Reputation", "", ""}) - for _, stat := range TopReputation { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{cases.Title(language.Und).String(stat.Key), fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top Classifications - topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) - if len(topClassification) > 0 { - csvRows = append(csvRows, []string{"🗂️ Top Classifications", "", ""}) - for _, stat := range topClassification { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top Behaviors - topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) - if len(topBehaviors) > 0 { - csvRows = append(csvRows, []string{"🤖 Top Behaviors", "", ""}) - for _, stat := range topBehaviors { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top Blocklists - topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) - if len(topBlocklists) > 0 { - csvRows = append(csvRows, []string{"⛔ Top Blocklists", "", ""}) - for _, stat := range topBlocklists { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top CVEs - topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) - if len(topCVEs) > 0 { - csvRows = append(csvRows, []string{"💥 Top CVEs", "", ""}) - for _, stat := range topCVEs { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top IP Ranges - TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) - if len(TopIPRange) > 0 { - csvRows = append(csvRows, []string{"🌐 Top IP Ranges", "", ""}) - for _, stat := range TopIPRange { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top Autonomous Systems - topAS := getTopN(stats.TopAS, maxTopDisplayReport) - if len(topAS) > 0 { - csvRows = append(csvRows, []string{"🛰️ Top Autonomous Systems", "", ""}) - for _, stat := range topAS { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } - - // Top Countries - topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) - if len(topCountry) > 0 { - csvRows = append(csvRows, []string{"🌎 Top Countries", "", ""}) - for _, stat := range topCountry { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - csvRows = append(csvRows, []string{stat.Key, fmt.Sprintf("%d", stat.Value), fmt.Sprintf("%.0f%%", percent)}) - } - csvRows = append(csvRows, []string{"", "", ""}) - } + fmt.Fprintln(writer, "") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") + fmt.Fprintln(writer, "Stats") + fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") - // Write all rows at once - for _, row := range csvRows { - if err := reportWriter.Write(row); err != nil { - return fmt.Errorf("failed to write CSV row: %v", err) + // Display top sections + for _, section := range data.TopSections { + fmt.Fprintf(writer, "%s:\n", section.Title) + for _, item := range section.Items { + displayKey := item.Key + if section.Title == "Top Reputation" { + displayKey = cases.Title(language.Und).String(item.Key) + } + fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", displayKey, item.Value, item.Percent) } + fmt.Fprintln(writer, "") } + writer.Flush() fmt.Printf("Report summary saved to: %s\n", reportFilename) // If detailed IP information is requested, save to a separate file - if withIPs { - detailsFilename := fmt.Sprintf("%s/details-%d.csv", outputFilePath, report.ID) + if len(data.IPTableData) > 1 { + detailsFilename := fmt.Sprintf("%s/details-%d.txt", outputFilePath, reportID) detailsFile, err := os.Create(detailsFilename) if err != nil { - return fmt.Errorf("failed to create details CSV file %s: %v", detailsFilename, err) + return fmt.Errorf("failed to create details text file %s: %v", detailsFilename, err) } defer detailsFile.Close() - detailsWriter := csv.NewWriter(detailsFile) - defer detailsWriter.Flush() - - // Collect all IP detail rows - var detailRows [][]string + detailsWriter := tabwriter.NewWriter(detailsFile, 0, 8, 2, ' ', 0) // Header - detailRows = append(detailRows, []string{ - "IP", "Country", "AS Name", "Reputation", "Confidence", - "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen", - }) - - // IP data - for _, ipItem := range report.IPs { - country := "N/A" - ipRange := "N/A" - asName := "N/A" - reverseDNS := "N/A" - - if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { - reverseDNS = *ipItem.ReverseDNS - } - if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { - country = *ipItem.Location.Country - } - if ipItem.IpRange != nil && *ipItem.IpRange != "" { - ipRange = *ipItem.IpRange - } - if ipItem.AsName != nil && *ipItem.AsName != "" { - asName = *ipItem.AsName - } - - behaviors := "" - for i, behavior := range ipItem.Behaviors { - if i > 0 { - behaviors += ", " - } - behaviors += behavior.Label - } - if behaviors == "" { - behaviors = "N/A" - } - - classif := "N/A" - if len(ipItem.Classifications.Classifications) > 0 { - for _, classification := range ipItem.Classifications.Classifications { - if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { - continue - } - classif = classification.Label - } - } - if len(ipItem.Classifications.FalsePositives) > 0 { - for _, classification := range ipItem.Classifications.FalsePositives { - classif = classification.Label - } - } - - firstSeen := "N/A" - lastSeen := "N/A" - if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { - firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] - } - if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { - lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] - } - - reputation := ipItem.Reputation - confidence := ipItem.Confidence - if reputation == "" { - reputation = "N/A" - confidence = "N/A" - } - - detailRows = append(detailRows, []string{ - ipItem.Ip, country, asName, reputation, confidence, - reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, - }) - } - - // Write all detail rows at once - for _, row := range detailRows { - if err := detailsWriter.Write(row); err != nil { - return fmt.Errorf("failed to write detail CSV row: %v", err) - } + fmt.Fprintln(detailsWriter, "IP\tCountry\tAS Name\tReputation\tConfidence\tReverse DNS\tProfile\tBehaviors\tRange") + fmt.Fprintln(detailsWriter, "─────────────────\t──────────\t─────────────────────────\t──────────\t──────────\t─────────────────────────\t─────────────────────────\t─────────────────────────\t─────────────────") + + // Write IP data rows (skip header row) + for i := 1; i < len(data.IPTableData); i++ { + row := data.IPTableData[i] + fmt.Fprintf(detailsWriter, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8], + ) } + detailsWriter.Flush() fmt.Printf("IP details saved to: %s\n", detailsFilename) } @@ -1141,227 +1032,58 @@ func saveReportJSON(report *models.Report, stats *models.ReportStats, withIPs bo return nil } -func saveReportHuman(report *models.Report, stats *models.ReportStats, withIPs bool, outputFilePath string) error { - // Save the report summary - reportFilename := fmt.Sprintf("%s/report-%d.txt", outputFilePath, report.ID) +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) reportFile, err := os.Create(reportFilename) if err != nil { - return fmt.Errorf("failed to create report text file %s: %v", reportFilename, err) + return fmt.Errorf("failed to create report CSV file %s: %v", reportFilename, err) } defer reportFile.Close() - // Use a tabwriter for consistent formatting - writer := tabwriter.NewWriter(reportFile, 0, 8, 1, '\t', tabwriter.AlignRight) - - // General section - fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") - fmt.Fprintln(writer, "General") - fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") - fmt.Fprintf(writer, "Report ID:\t%d\n", report.ID) - fmt.Fprintf(writer, "Report Name:\t%s\n", report.Name) - fmt.Fprintf(writer, "Creation Date:\t%s\n", report.CreatedAt.Format("2006-01-02 15:04:05")) - - if report.IsFile { - fmt.Fprintf(writer, "File path:\t%s\n", report.FilePath) - fmt.Fprintf(writer, "SHA256:\t%s\n", report.FileHash) - } - - if report.IsQuery { - fmt.Fprintf(writer, "Query:\t%s\n", report.Query) - fmt.Fprintf(writer, "Since Duration:\t%s\n", report.Since) - fmt.Fprintf(writer, "Since Time:\t%s\n", report.SinceTime.Format("2006-01-02 15:04:05")) - } - - fmt.Fprintf(writer, "Number of IPs:\t%d\n", len(report.IPs)) - - knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100 - ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100 - - fmt.Fprintf(writer, "Number of known IPs:\t%d (%.0f%%)\n", stats.NbIPs-stats.NbUnknownIPs, knownIPPercent) - fmt.Fprintf(writer, "Number of IPs in Blocklist:\t%d (%.0f%%)\n", stats.IPsBlockedByBlocklist, ipsInBlocklistPercent) - - // Stats section - fmt.Fprintln(writer, "") - fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") - fmt.Fprintln(writer, "Stats") - fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════") - - // Top Reputation - TopReputation := getTopN(stats.TopReputation, maxTopDisplayReport) - if len(TopReputation) > 0 { - fmt.Fprintln(writer, "Top Reputation:") - for _, stat := range TopReputation { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", cases.Title(language.Und).String(stat.Key), stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top Classifications - topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport) - if len(topClassification) > 0 { - fmt.Fprintln(writer, "Top Classifications:") - for _, stat := range topClassification { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top Behaviors - topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport) - if len(topBehaviors) > 0 { - fmt.Fprintln(writer, "Top Behaviors:") - for _, stat := range topBehaviors { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top Blocklists - topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport) - if len(topBlocklists) > 0 { - fmt.Fprintln(writer, "Top Blocklists:") - for _, stat := range topBlocklists { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top CVEs - topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport) - if len(topCVEs) > 0 { - fmt.Fprintln(writer, "Top CVEs:") - for _, stat := range topCVEs { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top IP Ranges - TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport) - if len(TopIPRange) > 0 { - fmt.Fprintln(writer, "Top IP Ranges:") - for _, stat := range TopIPRange { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } - - // Top Autonomous Systems - topAS := getTopN(stats.TopAS, maxTopDisplayReport) - if len(topAS) > 0 { - fmt.Fprintln(writer, "Top Autonomous Systems:") - for _, stat := range topAS { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) - } - fmt.Fprintln(writer, "") - } + reportWriter := csv.NewWriter(reportFile) + defer reportWriter.Flush() - // Top Countries - topCountry := getTopN(stats.TopCountries, maxTopDisplayReport) - if len(topCountry) > 0 { - fmt.Fprintln(writer, "Top Countries:") - for _, stat := range topCountry { - percent := float64(stat.Value) / float64(stats.NbIPs) * 100 - fmt.Fprintf(writer, " %s:\t%d (%.0f%%)\n", stat.Key, stat.Value, percent) + // Write all rows + for _, row := range csvReportRows { + if err := reportWriter.Write(row); err != nil { + return fmt.Errorf("failed to write CSV row: %v", err) } - fmt.Fprintln(writer, "") } - writer.Flush() fmt.Printf("Report summary saved to: %s\n", reportFilename) - // If detailed IP information is requested, save to a separate file - if withIPs { - detailsFilename := fmt.Sprintf("%s/details-%d.txt", outputFilePath, report.ID) + if len(csvDetailRows) > 1 { + detailsFilename := fmt.Sprintf("%s/details-%d.csv", outputFilePath, reportID) detailsFile, err := os.Create(detailsFilename) if err != nil { - return fmt.Errorf("failed to create details text file %s: %v", detailsFilename, err) + return fmt.Errorf("failed to create details CSV file %s: %v", detailsFilename, err) } defer detailsFile.Close() - detailsWriter := tabwriter.NewWriter(detailsFile, 0, 8, 2, ' ', 0) - - // Header - fmt.Fprintln(detailsWriter, "IP\tCountry\tAS Name\tReputation\tConfidence\tReverse DNS\tProfile\tBehaviors\tRange\tFirst Seen\tLast Seen") - fmt.Fprintln(detailsWriter, "─────────────────\t──────────\t─────────────────────────\t──────────\t──────────\t─────────────────────────\t─────────────────────────\t─────────────────────────\t─────────────────\t─────────────────────\t─────────────────────") - - for _, ipItem := range report.IPs { - country := "N/A" - ipRange := "N/A" - asName := "N/A" - reverseDNS := "N/A" - - if ipItem.ReverseDNS != nil && *ipItem.ReverseDNS != "" { - reverseDNS = *ipItem.ReverseDNS - } - if ipItem.Location.Country != nil && *ipItem.Location.Country != "" { - country = *ipItem.Location.Country - } - if ipItem.IpRange != nil && *ipItem.IpRange != "" { - ipRange = *ipItem.IpRange - } - if ipItem.AsName != nil && *ipItem.AsName != "" { - asName = *ipItem.AsName - } - - behaviors := "" - for i, behavior := range ipItem.Behaviors { - if i > 0 { - behaviors += ", " - } - behaviors += behavior.Label - } - if behaviors == "" { - behaviors = "N/A" - } - - classif := "N/A" - if len(ipItem.Classifications.Classifications) > 0 { - for _, classification := range ipItem.Classifications.Classifications { - if len(ipItem.Classifications.Classifications) > 1 && strings.ToLower(classification.Label) == "crowdsec community blocklist" { - continue - } - classif = classification.Label - } - } - if len(ipItem.Classifications.FalsePositives) > 0 { - for _, classification := range ipItem.Classifications.FalsePositives { - classif = classification.Label - } - } - - firstSeen := "N/A" - lastSeen := "N/A" - if ipItem.History.FirstSeen != nil && *ipItem.History.FirstSeen != "" { - firstSeen = strings.Split(*ipItem.History.FirstSeen, "+")[0] - } - if ipItem.History.LastSeen != nil && *ipItem.History.LastSeen != "" { - lastSeen = strings.Split(*ipItem.History.LastSeen, "+")[0] - } + detailsWriter := csv.NewWriter(detailsFile) + defer detailsWriter.Flush() - reputation := ipItem.Reputation - confidence := ipItem.Confidence - if reputation == "" { - reputation = "N/A" - confidence = "N/A" + // Write all detail rows + for _, row := range csvDetailRows { + if err := detailsWriter.Write(row); err != nil { + return fmt.Errorf("failed to write detail CSV row: %v", err) } - - fmt.Fprintf(detailsWriter, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - ipItem.Ip, country, asName, reputation, confidence, - reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen, - ) } - - detailsWriter.Flush() - fmt.Printf("IP details saved to: %s\n", detailsFilename) + fmt.Printf("IP details included in: %s\n", detailsFilename) } return nil } + +//// Utility functions + +func TruncateWithEllipsis(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return "..." + } + return s[:max-3] + "..." +} From e1860772dd7da6db65636f8531a97487eb035e4d Mon Sep 17 00:00:00 2001 From: jdv Date: Thu, 22 Jan 2026 09:46:23 +0100 Subject: [PATCH 7/7] updated readme with format and saving instructions --- README.md | 96 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c18bbb7..37246ee 100644 --- a/README.md +++ b/README.md @@ -15,36 +15,41 @@ Your ultimate IP dex! ## Table of Contents -- [Introduction](#introduction) -- [Prerequisites](#prerequisites) -- [Quickstart](#quickstart) - - [Install](#1-install) - - [Make sure the binary is in your PATH](#2-make-sure-the-binary-is-in-your-path) - - [Initialize the tool](#3-initialize-the-tool) - - [Query an IP](#4-query-an-ip) - - [Scan a file](#5-scan-a-file) -- [Configuration](#configuration) -- [User Guide](#user-guide) - - [Scan an IP](#scan-an-ip) - - [Refresh an IP](#refresh-an-ip) - - [Scan a file](#scan-a-file-1) - - [Refresh a file](#refresh-a-file) - - [Display all reports](#display-all-reports) - - [Showing a specific report](#showing-a-specific-report) -- [Commands](#commands) - - [`init`](#init) - - [`report`](#report) - - [List reports](#list-reports) - - [View a report](#view-a-report) - - [Delete a report](#delete-a-report) - - [`search`](#search) - - [Search IPs reported for a specific CVE](#search-ips-reported-for-a-specific-cve) - - [Search IPs reported for HTTP scan since 30 minutes](#search-ips-reported-for-http-scan-since-30-minutes) - - [Search malicious VPN or Proxy IPs since 1h and show all IPs](#search-malicious-vpn-or-proxy-ips-since-1h-and-show-all-ips) - - [`config`](#config) - - [Show config](#show-config) - - [Set a new API Key](#set-a-new-api-key) -- [License](#license) +- [ipdex](#ipdex) + - [Table of Contents](#table-of-contents) + - [Introduction](#introduction) + - [Prerequisites](#prerequisites) + - [Quickstart](#quickstart) + - [1. Install](#1-install) + - [Install with Go](#install-with-go) + - [macOS / Linux](#macos--linux) + - [Linux](#linux) + - [macOS](#macos) + - [Windows](#windows) + - [2. Make sure the binary is in your PATH](#2-make-sure-the-binary-is-in-your-path) + - [3. Initialize the tool](#3-initialize-the-tool) + - [4. Query an IP](#4-query-an-ip) + - [5. Scan a file](#5-scan-a-file) + - [Configuration](#configuration) + - [User Guide](#user-guide) + - [Scan an IP](#scan-an-ip) + - [Refresh an IP](#refresh-an-ip) + - [Scan a file](#scan-a-file) + - [Refresh a file](#refresh-a-file) + - [Output formats](#output-formats) + - [Saving reports to files](#saving-reports-to-files) + - [`report`](#report) + - [List reports](#list-reports) + - [View a report](#view-a-report) + - [Delete a report](#delete-a-report) + - [`search`](#search) + - [Search IPs reported for a specific CVE](#search-ips-reported-for-a-specific-cve) + - [Search IPs reported for HTTP scan since 30 minutes](#search-ips-reported-for-http-scan-since-30-minutes) + - [Search malicious VPN or Proxy IPs since 1h and show all IPs](#search-malicious-vpn-or-proxy-ips-since-1h-and-show-all-ips) + - [`config`](#config) + - [Show config](#show-config) + - [Set a new API Key](#set-a-new-api-key) + - [License](#license) --- @@ -162,6 +167,8 @@ ipdex /var/log/nginx.log

ipdex scanning a file

+**💡 Tip:** You can output results in different formats (`-o json`, `-o csv`) and save them to files using `--output-path`. See [Output formats](#output-formats) and [Saving reports to files](#saving-reports-to-files) for more details. + --- ## Configuration @@ -202,6 +209,31 @@ When running ipdex on a file that has been previously scanned, it will update th ipdex -r ``` +### Output formats + +ipdex supports multiple output formats to suit different use cases using the -o option: + +- **human** (default): Interactive, colorized output optimized for terminal viewing +- **json**: `-o json` Machine-readable JSON format for programmatic processing +- **csv**: `-o csv` Comma-separated values format for spreadsheet analysis + +### Saving reports to files + +You can save reports to disk using the `--output-path` flag. This works with all output formats and automatically creates separate files for the report summary and the detailed IP information (if you used the -d option). + +```bash +# Save report as CSV files report and details +ipdex ips.txt -o csv -d --output-path /path/to/output + +# This creates: +# - /path/to/output/report_.csv (summary statistics) +# - /path/to/output/report__details.csv (detailed IP information, when using -d flag) + +# You can also do it for an existing report +ipdex report show 18 -o csv --output-path /path/to/output -d + +**Note:** When using `--output-path`, reports are saved to files in addition to being displayed in the terminal. + ### Display all reports ``` @@ -242,7 +274,11 @@ ipdex report list #### View a report ```bash +# View a report in human-readable format ipdex report show 2 + +# View report with details as CSV and save +ipdex report show 2 -o csv --output-path ./exports -d ``` #### Delete a report