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
+**💡 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
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..d5f98ac 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
@@ -52,32 +54,78 @@ 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:
- 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)
}
return nil
}
-func displayIPJSON(item *cticlient.SmokeItem) error {
- jsonData, err := json.MarshalIndent(item, "", " ")
- if err != nil {
- return err
+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
+ }
+
+ if withIPs {
+ csvDetailRows = buildCSVDetailsRows(report)
+ if err := displayCSVRows(csvDetailRows); err != nil {
+ return err
+ }
+ }
+
+ if outputFilePath != "" {
+ if err := saveReportCSV(csvReportRows, csvDetailRows, int(report.ID), outputFilePath); err != nil {
+ return err
+ }
+ }
+ default:
+ return fmt.Errorf("format '%s' not supported", format)
}
- fmt.Printf("%s\n", jsonData)
+
return nil
}
-func displayIP(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error {
+/// IP display functions
+
+func displayIPHuman(item *cticlient.SmokeItem, ipLastRefresh time.Time, detailed bool) error {
keyStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("15"))
@@ -285,182 +333,183 @@ 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 {
- switch format {
- case HumanFormat:
- if err := displayReport(item, stats, withIPs); err != nil {
- return err
- }
- case JSONFormat:
- if err := displayReportJSON(item, stats); err != nil {
- return err
- }
- default:
- return fmt.Errorf("format '%s' not supported", format)
- }
- return nil
-}
-
-func displayReportJSON(item *models.Report, stats *models.ReportStats) error {
+func displayIPJSON(item *cticlient.SmokeItem) error {
jsonData, err := json.MarshalIndent(item, "", " ")
if err != nil {
return err
}
fmt.Printf("%s\n", jsonData)
+ return nil
+}
- jsonData, err = json.MarshalIndent(stats, "", " ")
- if err != nil {
- return err
+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))
}
- fmt.Printf("%s\n", jsonData)
- return nil
-}
+ // Extract timestamps
+ history := Format(item.History, FormatCSV)
+ timestamps := strings.Split(history, ",")
+ firstSeen, lastSeen := timestamps[0], timestamps[1]
-func TruncateWithEllipsis(s string, max int) string {
- if len(s) <= max {
- return s
+ // Define fields in order with header and data
+ type field struct {
+ header string
+ value string
}
- if max <= 3 {
- return "..."
+
+ 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)},
}
- return s[:max-3] + "..."
+
+ // 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 displayReport(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 {
@@ -473,37 +522,33 @@ func displayReport(report *models.Report, stats *models.ReportStats, withIPs boo
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
@@ -516,39 +561,529 @@ func displayReport(report *models.Report, stats *models.ReportStats, withIPs boo
}
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",
- "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())
}
}
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 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(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 report.IsFile {
+ rows = append(rows, []string{"File path", report.FilePath, ""})
+ rows = append(rows, []string{"SHA256", report.FileHash, ""})
+ }
+
+ 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(report.IPs)), ""})
+
+ knownIPPercent := float64(stats.NbIPs-stats.NbUnknownIPs) / float64(stats.NbIPs) * 100
+ ipsInBlocklistPercent := float64(stats.IPsBlockedByBlocklist) / float64(stats.NbIPs) * 100
+
+ 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
+ rows = append(rows, []string{"", "", ""})
+
+ // Stats section
+ 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{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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top Classifications
+ topClassification := getTopN(stats.TopClassifications, maxTopDisplayReport)
+ if len(topClassification) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top Behaviors
+ topBehaviors := getTopN(stats.TopBehaviors, maxTopDisplayReport)
+ if len(topBehaviors) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top Blocklists
+ topBlocklists := getTopN(stats.TopBlocklists, maxTopDisplayReport)
+ if len(topBlocklists) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top CVEs
+ topCVEs := getTopN(stats.TopCVEs, maxTopDisplayReport)
+ if len(topCVEs) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top IP Ranges
+ TopIPRange := getTopN(stats.TopIPRange, maxTopDisplayReport)
+ if len(TopIPRange) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top Autonomous Systems
+ topAS := getTopN(stats.TopAS, maxTopDisplayReport)
+ if len(topAS) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ // Top Countries
+ topCountry := getTopN(stats.TopCountries, maxTopDisplayReport)
+ if len(topCountry) > 0 {
+ 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)})
+ }
+ rows = append(rows, []string{"", "", ""})
+ }
+
+ return rows
+}
+
+func buildCSVDetailsRows(report *models.Report) [][]string {
+ var rows [][]string
+
+ //Header row
+ rows = append(rows, []string{
+ "IP", "Country", "AS Name", "Reputation", "Confidence",
+ "Reverse DNS", "Profile", "Behaviors", "Range", "First Seen", "Last Seen",
+ })
+
+ 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"
+ }
+
+ rows = append(rows, []string{
+ ipItem.Ip, country, asName, reputation, confidence,
+ reverseDNS, classif, behaviors, ipRange, firstSeen, lastSeen,
+ })
+ }
+
+ return rows
+}
+
+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 nil
+}
+
+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 text file %s: %v", reportFilename, err)
+ }
+ defer reportFile.Close()
+
+ writer := tabwriter.NewWriter(reportFile, 0, 8, 1, '\t', tabwriter.AlignRight)
+
+ // General section
+ 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)
+ }
+
+ // Stats section
+ fmt.Fprintln(writer, "")
+ fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════")
+ fmt.Fprintln(writer, "Stats")
+ fmt.Fprintln(writer, "═══════════════════════════════════════════════════════════════")
+
+ // 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 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 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")
+ 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)
+ }
+
+ 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 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 CSV file %s: %v", reportFilename, err)
+ }
+ defer reportFile.Close()
+
+ reportWriter := csv.NewWriter(reportFile)
+ defer reportWriter.Flush()
+
+ // 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.Printf("Report summary saved to: %s\n", reportFilename)
+
+ 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 CSV file %s: %v", detailsFilename, err)
+ }
+ defer detailsFile.Close()
+
+ detailsWriter := csv.NewWriter(detailsFile)
+ defer detailsWriter.Flush()
+
+ // 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.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] + "..."
+}
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 {