diff --git a/cli/gitcommands.go b/cli/gitcommands.go
index 8ea37082f..4c44cb061 100644
--- a/cli/gitcommands.go
+++ b/cli/gitcommands.go
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/jfrog/froggit-go/vcsutils"
- outputFormat "github.com/jfrog/jfrog-cli-core/v2/common/format"
"github.com/jfrog/jfrog-cli-core/v2/common/progressbar"
pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common"
"github.com/jfrog/jfrog-cli-core/v2/plugins/components"
@@ -14,6 +13,7 @@ import (
gitContributorsDocs "github.com/jfrog/jfrog-cli-security/cli/docs/git/contributors"
"github.com/jfrog/jfrog-cli-security/commands/git/audit"
"github.com/jfrog/jfrog-cli-security/commands/git/contributors"
+ "github.com/jfrog/jfrog-cli-security/utils/formats"
"github.com/jfrog/jfrog-cli-security/utils/xsc"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/log"
@@ -56,7 +56,7 @@ func GitAuditCmd(c *components.Context) error {
}
gitAuditCmd.SetServerDetails(serverDetails).SetXrayVersion(xrayVersion).SetXscVersion(xscVersion)
// Set violations params
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return err
}
diff --git a/cli/scancommands.go b/cli/scancommands.go
index 250de568f..2f64d007f 100644
--- a/cli/scancommands.go
+++ b/cli/scancommands.go
@@ -42,6 +42,7 @@ import (
"github.com/jfrog/jfrog-cli-security/commands/sast_server"
"github.com/jfrog/jfrog-cli-security/commands/source_mcp"
"github.com/jfrog/jfrog-cli-security/sca/bom/indexer"
+ "github.com/jfrog/jfrog-cli-security/utils/formats"
"github.com/jfrog/jfrog-cli-security/utils/xray"
"github.com/jfrog/jfrog-cli-security/commands/audit"
@@ -56,8 +57,10 @@ import (
"github.com/jfrog/jfrog-cli-security/utils/xsc"
)
-const DockerScanCmdHiddenName = "dockerscan"
-const SkipCurationAfterFailureEnv = "JFROG_CLI_SKIP_CURATION_AFTER_FAILURE"
+const (
+ DockerScanCmdHiddenName = "dockerscan"
+ SkipCurationAfterFailureEnv = "JFROG_CLI_SKIP_CURATION_AFTER_FAILURE"
+)
func getAuditAndScansCommands() []components.Command {
return []components.Command{
@@ -71,13 +74,14 @@ func getAuditAndScansCommands() []components.Command {
Action: ScanCmd,
},
{
- Name: "sbom-enrich",
- Aliases: []string{"se"},
- Flags: flags.GetCommandFlags(flags.Enrich),
- Description: enrichDocs.GetDescription(),
- Arguments: enrichDocs.GetArguments(),
- Category: securityCategory,
- Action: EnrichCmd,
+ Name: "sbom-enrich",
+ Aliases: []string{"se"},
+ Flags: flags.GetCommandFlags(flags.Enrich),
+ Description: enrichDocs.GetDescription(),
+ Arguments: enrichDocs.GetArguments(),
+ Category: securityCategory,
+ SupportedFormats: []outputFormat.OutputFormat{outputFormat.Json, outputFormat.Table},
+ Action: EnrichCmd,
},
{
Name: "malicious-scan",
@@ -211,7 +215,6 @@ func getAuditAndScansCommands() []components.Command {
}
func SourceMcpCmd(c *components.Context) error {
-
serverDetails, err := CreateServerDetailsFromFlags(c)
if err != nil {
return err
@@ -266,11 +269,16 @@ func EnrichCmd(c *components.Context) error {
if err != nil {
return err
}
- EnrichCmd := enrich.NewEnrichCommand().
+ format, err := c.GetOutputFormat()
+ if err != nil {
+ return err
+ }
+ enrichCmd := enrich.NewEnrichCommand().
SetServerDetails(serverDetails).
SetThreads(threads).
- SetSpec(specFile)
- return commandsCommon.Exec(EnrichCmd)
+ SetSpec(specFile).
+ SetOutputFormat(format)
+ return commandsCommon.Exec(enrichCmd)
}
func MaliciousScanCmd(c *components.Context) error {
@@ -281,7 +289,7 @@ func MaliciousScanCmd(c *components.Context) error {
if err = validateConnectionInputs(serverDetails); err != nil {
return err
}
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return err
}
@@ -318,7 +326,7 @@ func ScanCmd(c *components.Context) error {
if err != nil {
return err
}
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return err
}
@@ -437,7 +445,7 @@ func BuildScan(c *components.Context) error {
if err != nil {
return err
}
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return err
}
@@ -526,7 +534,7 @@ func CreateAuditCmd(c *components.Context) (string, string, *coreConfig.ServerDe
if err != nil {
return "", "", nil, nil, err
}
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return "", "", nil, nil, err
}
@@ -789,7 +797,7 @@ func DockerScan(c *components.Context, image string) error {
if err != nil {
return err
}
- format, err := outputFormat.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
+ format, err := formats.GetOutputFormat(c.GetStringFlagValue(flags.OutputFormat))
if err != nil {
return err
}
diff --git a/commands/enrich/enrich.go b/commands/enrich/enrich.go
index 74b068d8c..579fc1aa7 100644
--- a/commands/enrich/enrich.go
+++ b/commands/enrich/enrich.go
@@ -4,10 +4,14 @@ import (
"encoding/xml"
"errors"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
+ "strings"
+ "text/tabwriter"
+ coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format"
"github.com/jfrog/jfrog-cli-security/utils/results/output"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
@@ -31,14 +35,22 @@ import (
orderedJson "github.com/virtuald/go-ordered-json"
)
-type FileContext func(string) parallel.TaskFunc
-type indexFileHandlerFunc func(file string)
+type (
+ FileContext func(string) parallel.TaskFunc
+ indexFileHandlerFunc func(file string)
+)
type EnrichCommand struct {
serverDetails *config.ServerDetails
spec *spec.SpecFiles
threads int
progress ioUtils.ProgressMgr
+ outputFormat coreformat.OutputFormat
+}
+
+func (enrichCmd *EnrichCommand) SetOutputFormat(format coreformat.OutputFormat) *EnrichCommand {
+ enrichCmd.outputFormat = format
+ return enrichCmd
}
func (enrichCmd *EnrichCommand) SetProgress(progress ioUtils.ProgressMgr) {
@@ -129,6 +141,69 @@ func AppendVulnsToXML(cmdResults *results.SecurityCommandResults) error {
return nil
}
+func AppendVulnsFromXMLToJson(cmdResults *results.SecurityCommandResults) error {
+ fileName := getScaScanFileName(cmdResults)
+ doc := etree.NewDocument()
+ if err := doc.ReadFromFile(fileName); err != nil {
+ return fmt.Errorf("error reading XML file: %s", err.Error())
+ }
+ root := doc.Root()
+ if root == nil {
+ return fmt.Errorf("error parsing XML: no root element found")
+ }
+ data := xmlElementToOrderedJson(root)
+ xrayResults := cmdResults.GetScaScansXrayResults()
+ if len(xrayResults) == 0 {
+ return fmt.Errorf("xray scan results are empty")
+ } else if len(xrayResults) > 1 {
+ log.Warn("Received %d results, parsing only first result", len(xrayResults))
+ }
+ var vulnerabilities []map[string]string
+ for _, vuln := range xrayResults[0].Vulnerabilities {
+ for component := range vuln.Components {
+ vulnerability := map[string]string{"bom-ref": component, "id": vuln.Cves[0].Id}
+ vulnerabilities = append(vulnerabilities, vulnerability)
+ }
+ }
+ data = append(data, orderedJson.Member{Key: "vulnerabilities", Value: vulnerabilities})
+ return output.PrintJson(data)
+}
+
+func xmlElementToOrderedJson(el *etree.Element) orderedJson.OrderedObject {
+ var obj orderedJson.OrderedObject
+ tagCount := map[string]int{}
+ for _, child := range el.ChildElements() {
+ tagCount[child.Tag]++
+ }
+ seen := map[string]bool{}
+ for _, child := range el.ChildElements() {
+ if seen[child.Tag] {
+ continue
+ }
+ seen[child.Tag] = true
+ if tagCount[child.Tag] > 1 {
+ var arr []interface{}
+ for _, sibling := range el.ChildElements() {
+ if sibling.Tag == child.Tag {
+ if len(sibling.ChildElements()) == 0 {
+ arr = append(arr, strings.TrimSpace(sibling.Text()))
+ } else {
+ arr = append(arr, xmlElementToOrderedJson(sibling))
+ }
+ }
+ }
+ obj = append(obj, orderedJson.Member{Key: child.Tag, Value: arr})
+ } else {
+ if len(child.ChildElements()) == 0 {
+ obj = append(obj, orderedJson.Member{Key: child.Tag, Value: strings.TrimSpace(child.Text())})
+ } else {
+ obj = append(obj, orderedJson.Member{Key: child.Tag, Value: xmlElementToOrderedJson(child)})
+ }
+ }
+ }
+ return obj
+}
+
func isXML(scaResults []*results.TargetResults) (bool, error) {
if len(scaResults) == 0 {
return false, errors.New("unable to retrieve results")
@@ -180,7 +255,6 @@ func (enrichCmd *EnrichCommand) Run() (err error) {
if err = enrichCmd.progress.Quit(); err != nil {
return err
}
-
}
fileCollectingErr := fileCollectingErrorsQueue.GetError()
@@ -192,18 +266,27 @@ func (enrichCmd *EnrichCommand) Run() (err error) {
return errorutils.CheckError(scanResults.GetErrors())
}
+ if enrichCmd.outputFormat == coreformat.Table {
+ if err = enrichCmd.printVulnerabilitiesTable(scanResults, os.Stdout); err != nil {
+ return
+ }
+ log.Info("Enrich process completed successfully.")
+ return
+ }
isXml, err := isXML(scanResults.Targets)
if err != nil {
return
}
if isXml {
- if err = AppendVulnsToXML(scanResults); err != nil {
- return
- }
- } else {
- if err = AppendVulnsToJson(scanResults); err != nil {
+ if enrichCmd.outputFormat == coreformat.Json {
+ if err = AppendVulnsFromXMLToJson(scanResults); err != nil {
+ return
+ }
+ } else if err = AppendVulnsToXML(scanResults); err != nil {
return
}
+ } else if err = AppendVulnsToJson(scanResults); err != nil {
+ return
}
log.Info("Enrich process completed successfully.")
return nil
@@ -217,6 +300,27 @@ func (enrichCmd *EnrichCommand) CommandName() string {
return "xr_enrich"
}
+func (enrichCmd *EnrichCommand) printVulnerabilitiesTable(cmdResults *results.SecurityCommandResults, w io.Writer) error {
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+ if _, err := fmt.Fprintln(tw, "COMPONENT\tCVE-ID"); err != nil {
+ return err
+ }
+ for _, xrayResult := range cmdResults.GetScaScansXrayResults() {
+ for _, vuln := range xrayResult.Vulnerabilities {
+ cveID := ""
+ if len(vuln.Cves) > 0 {
+ cveID = vuln.Cves[0].Id
+ }
+ for component := range vuln.Components {
+ if _, err := fmt.Fprintf(tw, "%s\t%s\n", component, cveID); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return tw.Flush()
+}
+
func (enrichCmd *EnrichCommand) prepareScanTasks(fileProducer, indexedFileProducer parallel.Runner, cmdResults *results.SecurityCommandResults, fileCollectingErrorsQueue *clientutils.ErrorsQueue, xrayVersion string) {
go func() {
defer fileProducer.Done()
diff --git a/commands/enrich/enrich_test.go b/commands/enrich/enrich_test.go
new file mode 100644
index 000000000..0b13c709c
--- /dev/null
+++ b/commands/enrich/enrich_test.go
@@ -0,0 +1,194 @@
+package enrich
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/beevik/etree"
+ coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format"
+ "github.com/jfrog/jfrog-cli-security/utils"
+ "github.com/jfrog/jfrog-cli-security/utils/results"
+ "github.com/jfrog/jfrog-client-go/xray/services"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func makeCmdResultsWithVulns(vulns []services.Vulnerability) *results.SecurityCommandResults {
+ cmdResults := results.NewCommandResults(utils.SBOM)
+ target := cmdResults.NewScanResults(results.ScanTarget{Target: "test.json", Name: "test.json"})
+ target.ScaScanResults(0, services.ScanResponse{Vulnerabilities: vulns})
+ return cmdResults
+}
+
+func makeCmdResultsForFile(targetPath string, vulns []services.Vulnerability) *results.SecurityCommandResults {
+ cmdResults := results.NewCommandResults(utils.SBOM)
+ target := cmdResults.NewScanResults(results.ScanTarget{Target: targetPath, Name: filepath.Base(targetPath)})
+ target.ScaScanResults(0, services.ScanResponse{Vulnerabilities: vulns})
+ return cmdResults
+}
+
+func createTempXMLFile(t *testing.T, content string) string {
+ t.Helper()
+ f, err := os.CreateTemp(t.TempDir(), "test*.xml")
+ require.NoError(t, err)
+ _, err = f.WriteString(content)
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+ return f.Name()
+}
+
+func TestPrintVulnerabilitiesTable_WithFindings(t *testing.T) {
+ cmdResults := makeCmdResultsWithVulns([]services.Vulnerability{
+ {
+ Cves: []services.Cve{{Id: "CVE-2021-1234"}},
+ Components: map[string]services.Component{"pkg:npm/lodash@4.17.11": {}},
+ },
+ {
+ Cves: []services.Cve{{Id: "CVE-2020-9999"}},
+ Components: map[string]services.Component{"pkg:npm/minimist@1.2.5": {}},
+ },
+ })
+
+ cmd := &EnrichCommand{outputFormat: coreformat.Table}
+ var buf bytes.Buffer
+ err := cmd.printVulnerabilitiesTable(cmdResults, &buf)
+ require.NoError(t, err)
+
+ out := buf.String()
+ assert.Contains(t, out, "COMPONENT")
+ assert.Contains(t, out, "CVE-ID")
+ assert.Contains(t, out, "pkg:npm/lodash@4.17.11")
+ assert.Contains(t, out, "CVE-2021-1234")
+ assert.Contains(t, out, "pkg:npm/minimist@1.2.5")
+ assert.Contains(t, out, "CVE-2020-9999")
+}
+
+func TestPrintVulnerabilitiesTable_Empty(t *testing.T) {
+ cmdResults := makeCmdResultsWithVulns(nil)
+
+ cmd := &EnrichCommand{outputFormat: coreformat.Table}
+ var buf bytes.Buffer
+ err := cmd.printVulnerabilitiesTable(cmdResults, &buf)
+ require.NoError(t, err)
+
+ out := buf.String()
+ assert.Contains(t, out, "COMPONENT")
+ assert.Contains(t, out, "CVE-ID")
+ // no data rows
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ assert.Len(t, lines, 1)
+}
+
+func TestPrintVulnerabilitiesTable_NoCves(t *testing.T) {
+ cmdResults := makeCmdResultsWithVulns([]services.Vulnerability{
+ {
+ Cves: nil,
+ Components: map[string]services.Component{"pkg:go/golang.org/x/net@v0.0.0-20210226": {}},
+ },
+ })
+
+ cmd := &EnrichCommand{outputFormat: coreformat.Table}
+ var buf bytes.Buffer
+ err := cmd.printVulnerabilitiesTable(cmdResults, &buf)
+ require.NoError(t, err)
+
+ out := buf.String()
+ assert.Contains(t, out, "pkg:go/golang.org/x/net@v0.0.0-20210226")
+ // CVE-ID column is blank but row is present
+ assert.True(t, strings.Count(out, "\n") >= 2)
+}
+
+const minimalXML = `
+
+
+
+ lodash
+ 4.17.11
+
+
+ minimist
+ 1.2.5
+
+
+`
+
+func testVulns() []services.Vulnerability {
+ return []services.Vulnerability{
+ {
+ Cves: []services.Cve{{Id: "CVE-2021-1234"}},
+ Components: map[string]services.Component{"pkg:npm/lodash@4.17.11": {}},
+ },
+ }
+}
+
+func TestXmlElementToOrderedJson(t *testing.T) {
+ doc := etree.NewDocument()
+ err := doc.ReadFromString(`
+ text
+ a
+ b
+ val
+ `)
+ require.NoError(t, err)
+
+ result := xmlElementToOrderedJson(doc.Root())
+
+ require.Len(t, result, 3)
+
+ assert.Equal(t, "single", result[0].Key)
+ assert.Equal(t, "text", result[0].Value)
+
+ assert.Equal(t, "repeated", result[1].Key)
+ arr, ok := result[1].Value.([]interface{})
+ require.True(t, ok)
+ assert.Equal(t, []interface{}{"a", "b"}, arr)
+
+ assert.Equal(t, "nested", result[2].Key)
+}
+
+func TestAppendVulnsFromXMLToJson(t *testing.T) {
+ xmlFile := createTempXMLFile(t, minimalXML)
+
+ tests := []struct {
+ name string
+ cmdResults *results.SecurityCommandResults
+ wantErr string
+ }{
+ {
+ name: "success",
+ cmdResults: makeCmdResultsForFile(xmlFile, testVulns()),
+ },
+ {
+ name: "empty xray results",
+ cmdResults: func() *results.SecurityCommandResults {
+ r := results.NewCommandResults(utils.SBOM)
+ r.NewScanResults(results.ScanTarget{Target: xmlFile, Name: "test.xml"})
+ return r
+ }(),
+ wantErr: "xray scan results are empty",
+ },
+ {
+ name: "invalid file",
+ cmdResults: func() *results.SecurityCommandResults {
+ r := results.NewCommandResults(utils.SBOM)
+ r.NewScanResults(results.ScanTarget{Target: "/nonexistent/file.xml", Name: "file.xml"})
+ return r
+ }(),
+ wantErr: "error reading XML file",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := AppendVulnsFromXMLToJson(tt.cmdResults)
+ if tt.wantErr != "" {
+ assert.ErrorContains(t, err, tt.wantErr)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index 38d8adfb4..36d86ae11 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
github.com/jfrog/gofrog v1.7.6
github.com/jfrog/jfrog-apps-config v1.0.1
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305
- github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3
+ github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260428135824-dbef60cb4319
github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7
github.com/magiconair/properties v1.8.10
github.com/owenrumney/go-sarif/v3 v3.2.3
diff --git a/go.sum b/go.sum
index 35e80e618..0b0c906e6 100644
--- a/go.sum
+++ b/go.sum
@@ -173,6 +173,8 @@ github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305 h1:w
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260423195010-d7aa2c437305/go.mod h1:6QJFQvde/CLnFeIIFOvm/6QuQr8OT1QWiTJAkQ+1Mnc=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3 h1:LdLQQmhOMUfU+3x7wbtB7kY/Dd2LXKHz7CCUpHWn7uM=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260427010241-873f53d940b3/go.mod h1:qpD7einonjqskDTEyqeG3NzAbZO6se0s0Pet0ObBQ3I=
+github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260428135824-dbef60cb4319 h1:3q0lNklwvW7icWAKR4cmEmwi3RNZEWtXRTuXOTFva64=
+github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260428135824-dbef60cb4319/go.mod h1:qpD7einonjqskDTEyqeG3NzAbZO6se0s0Pet0ObBQ3I=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7 h1:MvHnFczVntYB/USj7/RRANvdWbTUcwEvXcIGr7lOyTc=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
diff --git a/utils/formats/output_format.go b/utils/formats/output_format.go
new file mode 100644
index 000000000..d9e0ea5ac
--- /dev/null
+++ b/utils/formats/output_format.go
@@ -0,0 +1,13 @@
+package formats
+
+import (
+ outputFormat "github.com/jfrog/jfrog-cli-core/v2/common/format"
+)
+
+func GetOutputFormat(format string) (f outputFormat.OutputFormat, err error) {
+ f = outputFormat.Table
+ if format != "" {
+ f, err = outputFormat.ParseOutputFormat(format, outputFormat.All)
+ }
+ return
+}