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 +}