Skip to content
98 changes: 96 additions & 2 deletions cmd/vulnx/clis/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ var (
// Add global no-color flag
noColor bool

// Add global csv output flag
csvFile string
Comment thread
lorenzocamilli marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Global disable update check flag
globalDisableUpdateCheck bool

Expand Down Expand Up @@ -93,6 +96,10 @@ var (
showVersionInfo()
}
}
if err := validateOutputFlags(); err != nil {
return err
}

err := ensureVulnxClientInitialized(cmd)
if err != nil {
return err
Expand Down Expand Up @@ -186,6 +193,9 @@ func init() {
// Add persistent no-color flag
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output")

// Add persistent csv output flag
rootCmd.PersistentFlags().StringVar(&csvFile, "csv", "", "write output to CSV file (error if file exists)")

// Add persistent disable update check flag
rootCmd.PersistentFlags().BoolVar(&globalDisableUpdateCheck, "disable-update-check", false, "disable automatic vulnx update check")

Expand Down Expand Up @@ -495,6 +505,58 @@ func removeDuplicateStrings(ids []string) []string {
return result
}

// csvRequiredFields are the API field names needed to populate every CSV column.
var csvRequiredFields = []string{
"doc_id", "name", "severity", "cvss_score", "epss_score",
"is_kev", "is_template", "poc_count", "h1",
"is_patch_available", "age_in_days", "affected_products", "tags",
}

// mergeFields returns base with any elements from extra appended that are not already present.
func mergeFields(base, extra []string) []string {
if len(base) == 0 {
return base // no field restriction at all — API returns everything
}
seen := make(map[string]bool, len(base))
for _, f := range base {
seen[f] = true
}
result := make([]string, len(base))
copy(result, base)
for _, f := range extra {
if !seen[f] {
result = append(result, f)
}
}
return result
}

// validateOutputFlags enforces mutual exclusivity and extension rules for the
// three output-mode flags (--json, --output, --csv). Called from PersistentPreRunE
// so it applies uniformly to every subcommand, including the stdin auto-detect path.
func validateOutputFlags() error {
outputModes := 0
if jsonOutput {
outputModes++
}
if outputFile != "" {
outputModes++
}
if csvFile != "" {
outputModes++
}
if outputModes > 1 {
return fmt.Errorf("--json, --output, and --csv are mutually exclusive; specify at most one")
}
if outputFile != "" && !strings.HasSuffix(outputFile, ".json") {
return fmt.Errorf("--output file must have a .json extension")
}
if csvFile != "" && !strings.HasSuffix(csvFile, ".csv") {
return fmt.Errorf("--csv file must have a .csv extension")
}
return nil
}

func executeIDWithIDs(cveIDs []string) error {
// Call the id command logic directly
return runIDCommandWithIDs(cveIDs)
Expand All @@ -510,8 +572,8 @@ func runIDCommandWithIDs(cveIDs []string) error {
// Use the global vulnxClient
handler := id.NewHandler(vulnxClient)

// Handle JSON output for multiple IDs
if jsonOutput || outputFile != "" {
// Handle JSON/CSV output for multiple IDs
if jsonOutput || outputFile != "" || csvFile != "" {
var allVulns []*vulnx.Vulnerability
for _, vulnID := range cveIDs {
vuln, err := handler.Get(vulnID)
Expand All @@ -530,6 +592,38 @@ func runIDCommandWithIDs(cveIDs []string) error {
gologger.Fatal().Msg("No vulnerabilities were successfully retrieved")
}

// Handle CSV output
if csvFile != "" {
csvEntries := make([]*renderer.Entry, 0, len(allVulns))
for _, vuln := range allVulns {
entry := renderer.FromVulnerability(vuln)
if entry != nil {
csvEntries = append(csvEntries, entry)
}
}
csvBytes, err := renderer.RenderCSV(csvEntries)
if err != nil {
gologger.Fatal().Msgf("Failed to render CSV: %s", err)
}
if _, err := os.Stat(csvFile); err == nil {
gologger.Fatal().Msgf("Output file already exists: %s", csvFile)
}
f, err := os.OpenFile(csvFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
gologger.Fatal().Msgf("Failed to create output file: %s", err)
}
defer func() {
if err := f.Close(); err != nil {
gologger.Error().Msgf("Failed to close output file: %s", err)
}
}()
if _, err := f.Write(csvBytes); err != nil {
gologger.Fatal().Msgf("Failed to write to output file: %s", err)
}
gologger.Info().Msgf("Wrote %d vulnerability(s) to file: %s", len(allVulns), csvFile)
return nil
}

// Marshal single item or array based on input
var jsonBytes []byte
var err error
Expand Down
40 changes: 38 additions & 2 deletions cmd/vulnx/clis/id.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ vulnx id --no-color CVE-2024-1234
if len(vulnIDs) == 0 {
gologger.Fatal().Msg("No vulnerability IDs provided. Use command line arguments, --file, or pipe IDs via stdin")
}


if csvFile != "" && !strings.HasSuffix(csvFile, ".csv") {
gologger.Fatal().Msg("csv output file must have .csv extension")
}
// Remove duplicates and validate IDs
vulnIDs = removeDuplicates(vulnIDs)
for _, vulnID := range vulnIDs {
Expand All @@ -124,7 +127,7 @@ vulnx id --no-color CVE-2024-1234
handler := id.NewHandler(vulnxClient)

// Handle JSON output for multiple IDs
if jsonOutput || outputFile != "" {
if jsonOutput || outputFile != "" || csvFile != "" {
var allVulns []*vulnx.Vulnerability
for _, vulnID := range vulnIDs {
vuln, err := handler.Get(vulnID)
Expand All @@ -146,6 +149,39 @@ vulnx id --no-color CVE-2024-1234
gologger.Fatal().Msg("No vulnerabilities were successfully retrieved")
}

// Handle CSV output
if csvFile != "" {
csvEntries := make([]*renderer.Entry, 0, len(allVulns))
for _, vuln := range allVulns {
entry := renderer.FromVulnerability(vuln)
if entry != nil {
csvEntries = append(csvEntries, entry)
}
}
csvBytes, err := renderer.RenderCSV(csvEntries)
if err != nil {
gologger.Fatal().Msgf("Failed to render CSV: %s", err)
}
// Check if file exists
if _, err := os.Stat(csvFile); err == nil {
gologger.Fatal().Msgf("Output file already exists: %s", csvFile)
}
f, err := os.OpenFile(csvFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
gologger.Fatal().Msgf("Failed to create output file: %s", err)
}
defer func() {
if err := f.Close(); err != nil {
gologger.Error().Msgf("Failed to close output file: %s", err)
}
}()
if _, err := f.Write(csvBytes); err != nil {
gologger.Fatal().Msgf("Failed to write to output file: %s", err)
}
gologger.Info().Msgf("Wrote %d vulnerability(s) to file: %s", len(allVulns), csvFile)
return
}

// Marshal single item or array based on input
var jsonBytes []byte
var err error
Expand Down
45 changes: 38 additions & 7 deletions cmd/vulnx/clis/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ vulnx search --term-facets tags=10,severity=4 "is_remote:true"
if len(searchFields) > 0 {
params.Fields = searchFields
}
// When writing CSV, ensure all columns the exporter needs are present
// even if the user narrowed the response via --fields.
if csvFile != "" {
params.Fields = mergeFields(params.Fields, csvRequiredFields)
}
if len(searchTermFacets) > 0 {
params.TermFacets = searchTermFacets
for i, facet := range params.TermFacets {
Expand Down Expand Up @@ -243,6 +248,39 @@ vulnx search --term-facets tags=10,severity=4 "is_remote:true"
return
}

// Handle CSV output
Comment thread
lorenzocamilli marked this conversation as resolved.
if csvFile != "" {
csvEntries := make([]*renderer.Entry, 0, len(resp.Results))
for _, vuln := range resp.Results {
entry := renderer.FromVulnerability(&vuln)
if entry != nil {
csvEntries = append(csvEntries, entry)
}
}
csvBytes, err := renderer.RenderCSV(csvEntries)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil {
gologger.Fatal().Msgf("Failed to render CSV: %s", err)
}
// Check if file exists
if _, err := os.Stat(csvFile); err == nil {
gologger.Fatal().Msgf("Output file already exists: %s", csvFile)
}
f, err := os.OpenFile(csvFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
gologger.Fatal().Msgf("Failed to create output file: %s", err)
}
defer func() {
if err := f.Close(); err != nil {
gologger.Error().Msgf("Failed to close output file: %s", err)
}
}()
if _, err := f.Write(csvBytes); err != nil {
gologger.Fatal().Msgf("Failed to write to output file: %s", err)
}
gologger.Info().Msgf("Wrote output to file: %s", csvFile)
return
}

// Default CLI renderer format
layout, err := renderer.ParseLayout([]byte(defaultLayoutJSON))
if err != nil {
Expand Down Expand Up @@ -583,13 +621,6 @@ func validateSearchInputs() error {
return fmt.Errorf("facet-size must be between 1 and 1000")
}

// Validate output file path if specified
if outputFile != "" {
if !strings.HasSuffix(outputFile, ".json") {
return fmt.Errorf("output file must have .json extension")
}
}

return nil
}

Expand Down
26 changes: 26 additions & 0 deletions pkg/tools/renderer/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ func truncateProductName(productName string, maxLength int) string {
return productName[:maxLength-3] + "..."
}

// distinctVendors returns untruncated distinct vendor names, suitable for data-export formats.
func distinctVendors(products []*vulnx.ProductInfo) []string {
seen := make(map[string]bool)
var vendors []string
for _, p := range products {
if p != nil && p.Vendor != "" && !seen[p.Vendor] {
seen[p.Vendor] = true
vendors = append(vendors, p.Vendor)
}
}
return vendors
}

// distinctProducts returns untruncated distinct product names, suitable for data-export formats.
func distinctProducts(products []*vulnx.ProductInfo) []string {
seen := make(map[string]bool)
var names []string
for _, p := range products {
if p != nil && p.Product != "" && !seen[p.Product] {
seen[p.Product] = true
names = append(names, p.Product)
}
}
return names
}

// extractDistinctVendors extracts distinct vendors from a slice of ProductInfo
func extractDistinctVendors(products []*vulnx.ProductInfo) []string {
seen := make(map[string]bool)
Expand Down
48 changes: 48 additions & 0 deletions pkg/tools/renderer/renderer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package renderer

import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"strconv"
Expand All @@ -12,6 +14,52 @@ func Render(entries []*Entry, layout []LayoutLine, totalResults, shownResults in
return RenderWithColors(entries, layout, totalResults, shownResults, DefaultColorConfig())
}

// RenderCSV generates a CSV representation of vulnerability entries.
// Columns: id, severity, cvss_score, epss_score, is_kev, is_template, poc_count,
// hackerone, is_patch_available, age_in_days, vendors, products, tags, title
func RenderCSV(entries []*Entry) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)

header := []string{
"id", "severity", "cvss_score", "epss_score",
"is_kev", "is_template", "poc_count", "hackerone",
"is_patch_available", "age_in_days", "vendors", "products", "tags", "title",
}
if err := w.Write(header); err != nil {
return nil, err
}

for _, e := range entries {
vendors := distinctVendors(e.AffectedProducts)
products := distinctProducts(e.AffectedProducts)
hackerone := e.H1 != nil && e.H1.Reports > 0

row := []string{
e.DocID,
e.Severity,
strconv.FormatFloat(e.CvssScore, 'f', -1, 64),
strconv.FormatFloat(e.EpssScore, 'f', -1, 64),
strconv.FormatBool(e.IsKev),
strconv.FormatBool(e.IsTemplate),
strconv.Itoa(e.PocCount),
strconv.FormatBool(hackerone),
strconv.FormatBool(e.IsPatchAvailable),
strconv.Itoa(e.AgeInDays),
strings.Join(vendors, ";"),
strings.Join(products, ";"),
strings.Join(e.Tags, ";"),
e.Name,
}
if err := w.Write(row); err != nil {
return nil, err
}
}

w.Flush()
return buf.Bytes(), w.Error()
}

// RenderDetailed generates detailed formatted output for a single vulnerability
func RenderDetailed(entry *Entry, colors *ColorConfig) string {
if entry == nil {
Expand Down