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 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 {