From ccd59bfb5e0e7d81ab693e3394fd94317e00d4aa Mon Sep 17 00:00:00 2001 From: Assaf Attias <49212512+attiasas@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:08:19 +0200 Subject: [PATCH] Add option to get SBOM information (#307) --- cli/docs/flags.go | 8 +- cli/scancommands.go | 12 + commands/audit/audit.go | 10 +- commands/audit/audit_test.go | 9 +- commands/audit/scarunner.go | 7 +- commands/enrich/enrich.go | 2 +- commands/git/audit/gitaudit.go | 4 +- commands/scan/buildscan.go | 2 +- commands/scan/scan.go | 7 +- jas/runner/jasrunner_test.go | 2 +- utils/formats/table.go | 11 + utils/results/common.go | 101 ++++++- utils/results/common_test.go | 260 ++++++++++++++++++ utils/results/conversion/convertor.go | 8 + utils/results/conversion/convertor_test.go | 4 +- .../conversion/sarifparser/sarifparser.go | 5 + .../simplejsonparser/simplejsonparser.go | 5 + .../conversion/summaryparser/summaryparser.go | 5 + .../conversion/tableparser/tableparser.go | 41 ++- utils/results/output/resultwriter.go | 16 +- utils/results/results.go | 27 +- utils/validations/test_validate_sca.go | 2 +- utils/validations/test_validation.go | 31 ++- utils/xsc/analyticsmetrics_test.go | 2 +- 24 files changed, 555 insertions(+), 26 deletions(-) diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 08a4830c..54798d29 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -99,6 +99,7 @@ const ( Watches = "watches" RepoPath = "repo-path" Licenses = "licenses" + Sbom = "sbom" Fail = "fail" ExtendedTable = "extended-table" MinSeverity = "min-severity" @@ -144,7 +145,7 @@ var commandFlags = map[string][]string{ OfflineUpdate: {LicenseId, From, To, Version, Target, Stream, Periodic}, XrScan: { url, user, password, accessToken, ServerId, SpecFlag, Threads, scanRecursive, scanRegexp, scanAnt, - Project, Watches, RepoPath, Licenses, OutputFormat, Fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, ScanVuln, + Project, Watches, RepoPath, Licenses, Sbom, OutputFormat, Fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, ScanVuln, }, Enrich: { url, user, password, accessToken, ServerId, Threads, @@ -153,10 +154,10 @@ var commandFlags = map[string][]string{ url, user, password, accessToken, ServerId, Project, BuildVuln, OutputFormat, Fail, ExtendedTable, Rescan, }, DockerScan: { - ServerId, Project, Watches, RepoPath, Licenses, OutputFormat, Fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, ScanVuln, SecretValidation, + ServerId, Project, Watches, RepoPath, Licenses, Sbom, OutputFormat, Fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, ScanVuln, SecretValidation, }, Audit: { - url, xrayUrl, user, password, accessToken, ServerId, InsecureTls, Project, Watches, RepoPath, Licenses, OutputFormat, ExcludeTestDeps, + url, xrayUrl, user, password, accessToken, ServerId, InsecureTls, Project, Watches, RepoPath, Sbom, Licenses, OutputFormat, ExcludeTestDeps, useWrapperAudit, DepType, RequirementsFile, Fail, ExtendedTable, WorkingDirs, ExclusionsAudit, Mvn, Gradle, Npm, Pnpm, Yarn, Go, Nuget, Pip, Pipenv, Poetry, MinSeverity, FixableOnly, ThirdPartyContextualAnalysis, Threads, Sca, Iac, Sast, Secrets, WithoutCA, ScanVuln, SecretValidation, OutputDir, SkipAutoInstall, AllowPartialResults, MaxTreeDepth, @@ -226,6 +227,7 @@ var flagsMap = map[string]components.Flag{ Watches: components.NewStringFlag(Watches, "A comma-separated(,) list of Xray watches, to enable Xray to determine violations accordingly. The command accepts this option only if the --project and --repo-path options are not provided. If none of the three options are provided, the command will show all known vulnerabilities."), RepoPath: components.NewStringFlag(RepoPath, "Artifactory repository path, to enable Xray to determine violations accordingly. The command accepts this option only if the --project and --watches options are not provided. If none of the three options are provided, the command will show all known vulnerabilities."), Licenses: components.NewBoolFlag(Licenses, "Set if you'd also like the list of licenses to be displayed."), + Sbom: components.NewBoolFlag(Sbom, fmt.Sprintf("For displaying the SBOM for this project, set to true. Relevant only with --%s flag. Ignored if provided 'format' is not 'table'.", Sca)), OutputFormat: components.NewStringFlag( OutputFormat, "Defines the output format of the command. Acceptable values are: table, json, simple-json and sarif. Note: the json format doesn't include information about scans that are included as part of the Advanced Security package.", diff --git a/cli/scancommands.go b/cli/scancommands.go index 502a61b8..3eb215a5 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -229,6 +229,9 @@ func ScanCmd(c *components.Context) error { if err != nil { return err } + if c.GetBoolFlagValue(flags.Sbom) && format != outputFormat.Table { + log.Warn("The '--sbom' flag is only supported with the 'table' output format. Ignoring the flag.") + } pluginsCommon.FixWinPathsForFileSystemSourcedCmds(specFile, c) minSeverity, err := getMinimumSeverity(c) if err != nil { @@ -245,6 +248,7 @@ func ScanCmd(c *components.Context) error { SetBaseRepoPath(repoPath). SetIncludeVulnerabilities(c.GetBoolFlagValue(flags.Vuln) || shouldIncludeVulnerabilities(c)). SetIncludeLicenses(c.GetBoolFlagValue(flags.Licenses)). + SetIncludeSbom(c.GetBoolFlagValue(flags.Sbom)). SetFail(c.GetBoolFlagValue(flags.Fail)). SetPrintExtendedTable(c.GetBoolFlagValue(flags.ExtendedTable)). SetBypassArchiveLimits(c.GetBoolFlagValue(flags.BypassArchiveLimits)). @@ -393,6 +397,9 @@ func CreateAuditCmd(c *components.Context) (string, string, *coreConfig.ServerDe if err != nil { return "", "", nil, nil, err } + if c.GetBoolFlagValue(flags.Sbom) && format != outputFormat.Table { + log.Warn("The '--sbom' flag is only supported with the 'table' output format. Ignoring the flag.") + } minSeverity, err := getMinimumSeverity(c) if err != nil { return "", "", nil, nil, err @@ -406,6 +413,7 @@ func CreateAuditCmd(c *components.Context) (string, string, *coreConfig.ServerDe SetProject(getProject(c)). SetIncludeVulnerabilities(c.GetBoolFlagValue(flags.Vuln)). SetIncludeLicenses(c.GetBoolFlagValue(flags.Licenses)). + SetIncludeSbom(c.GetBoolFlagValue(flags.Sbom)). SetFail(c.GetBoolFlagValue(flags.Fail)). SetPrintExtendedTable(c.GetBoolFlagValue(flags.ExtendedTable)). SetMinSeverityFilter(minSeverity). @@ -651,6 +659,9 @@ func DockerScan(c *components.Context, image string) error { if err != nil { return err } + if c.GetBoolFlagValue(flags.Sbom) && format != outputFormat.Table { + log.Warn("The '--sbom' flag is only supported with the 'table' output format. Ignoring the flag.") + } minSeverity, err := getMinimumSeverity(c) if err != nil { return err @@ -664,6 +675,7 @@ func DockerScan(c *components.Context, image string) error { SetBaseRepoPath(addTrailingSlashToRepoPathIfNeeded(c)). SetIncludeVulnerabilities(c.GetBoolFlagValue(flags.Vuln) || shouldIncludeVulnerabilities(c)). SetIncludeLicenses(c.GetBoolFlagValue(flags.Licenses)). + SetIncludeSbom(c.GetBoolFlagValue(flags.Sbom)). SetFail(c.GetBoolFlagValue(flags.Fail)). SetPrintExtendedTable(c.GetBoolFlagValue(flags.ExtendedTable)). SetBypassArchiveLimits(c.GetBoolFlagValue(flags.BypassArchiveLimits)). diff --git a/commands/audit/audit.go b/commands/audit/audit.go index 19f2788f..45150f37 100644 --- a/commands/audit/audit.go +++ b/commands/audit/audit.go @@ -40,6 +40,7 @@ type AuditCommand struct { targetRepoPath string IncludeVulnerabilities bool IncludeLicenses bool + IncludeSbom bool Fail bool PrintExtendedTable bool Threads int @@ -80,6 +81,11 @@ func (auditCmd *AuditCommand) SetIncludeLicenses(include bool) *AuditCommand { return auditCmd } +func (auditCmd *AuditCommand) SetIncludeSbom(include bool) *AuditCommand { + auditCmd.IncludeSbom = include + return auditCmd +} + func (auditCmd *AuditCommand) SetFail(fail bool) *AuditCommand { auditCmd.Fail = fail return auditCmd @@ -96,13 +102,14 @@ func (auditCmd *AuditCommand) SetThreads(threads int) *AuditCommand { } // Create a results context based on the provided parameters. resolves conflicts between the parameters based on the retrieved platform watches. -func CreateAuditResultsContext(serverDetails *config.ServerDetails, xrayVersion string, watches []string, artifactoryRepoPath, projectKey, gitRepoHttpsCloneUrl string, includeVulnerabilities, includeLicenses bool) (context results.ResultContext) { +func CreateAuditResultsContext(serverDetails *config.ServerDetails, xrayVersion string, watches []string, artifactoryRepoPath, projectKey, gitRepoHttpsCloneUrl string, includeVulnerabilities, includeLicenses, includeSbom bool) (context results.ResultContext) { context = results.ResultContext{ RepoPath: artifactoryRepoPath, Watches: watches, ProjectKey: projectKey, IncludeVulnerabilities: shouldIncludeVulnerabilities(includeVulnerabilities, watches, artifactoryRepoPath, projectKey, ""), IncludeLicenses: includeLicenses, + IncludeSbom: includeSbom, } if err := clientutils.ValidateMinimumVersion(clientutils.Xray, xrayVersion, services.MinXrayVersionGitRepoKey); err != nil { // Git repo key is not supported by the Xray version. @@ -173,6 +180,7 @@ func (auditCmd *AuditCommand) Run() (err error) { auditCmd.gitRepoHttpsCloneUrl, auditCmd.IncludeVulnerabilities, auditCmd.IncludeLicenses, + auditCmd.IncludeSbom, )). SetThirdPartyApplicabilityScan(auditCmd.thirdPartyApplicabilityScan). SetThreads(auditCmd.Threads). diff --git a/commands/audit/audit_test.go b/commands/audit/audit_test.go index cc42072d..5e2e5a3c 100644 --- a/commands/audit/audit_test.go +++ b/commands/audit/audit_test.go @@ -666,6 +666,7 @@ func TestCreateResultsContext(t *testing.T) { jfrogProjectKey string includeVulnerabilities bool includeLicenses bool + includeSbom bool expectedArtifactoryRepoPath string expectedHttpCloneUrl string @@ -673,13 +674,16 @@ func TestCreateResultsContext(t *testing.T) { expectedJfrogProjectKey string expectedIncludeVulnerabilities bool expectedIncludeLicenses bool + expectedIncludeSbom bool }{ { name: "Only Vulnerabilities", includeLicenses: true, + includeSbom: true, // Since no violation context is provided, the includeVulnerabilities flag should be set to true even if not provided expectedIncludeVulnerabilities: true, expectedIncludeLicenses: true, + expectedIncludeSbom: true, }, { name: "Watches", @@ -711,25 +715,28 @@ func TestCreateResultsContext(t *testing.T) { jfrogProjectKey: mockProjectKey, includeVulnerabilities: true, includeLicenses: true, + includeSbom: true, expectedHttpCloneUrl: testCaseExpectedGitRepoHttpsCloneUrl, expectedWatches: mockWatches, expectedJfrogProjectKey: mockProjectKey, expectedIncludeVulnerabilities: true, expectedIncludeLicenses: true, + expectedIncludeSbom: true, }, } for _, testCase := range testCases { t.Run(fmt.Sprintf("%s - %s", test.name, testCase.name), func(t *testing.T) { mockServer, serverDetails := validations.XrayServer(t, validations.MockServerParams{XrayVersion: test.xrayVersion, ReturnMockPlatformWatches: test.expectedPlatformWatches}) defer mockServer.Close() - context := CreateAuditResultsContext(serverDetails, test.xrayVersion, testCase.watches, testCase.artifactoryRepoPath, testCase.jfrogProjectKey, testCase.httpCloneUrl, testCase.includeVulnerabilities, testCase.includeLicenses) + context := CreateAuditResultsContext(serverDetails, test.xrayVersion, testCase.watches, testCase.artifactoryRepoPath, testCase.jfrogProjectKey, testCase.httpCloneUrl, testCase.includeVulnerabilities, testCase.includeLicenses, testCase.includeSbom) assert.Equal(t, testCase.expectedArtifactoryRepoPath, context.RepoPath) assert.Equal(t, testCase.expectedHttpCloneUrl, context.GitRepoHttpsCloneUrl) assert.Equal(t, testCase.expectedWatches, context.Watches) assert.Equal(t, testCase.expectedJfrogProjectKey, context.ProjectKey) assert.Equal(t, testCase.expectedIncludeVulnerabilities, context.IncludeVulnerabilities) assert.Equal(t, testCase.expectedIncludeLicenses, context.IncludeLicenses) + assert.Equal(t, testCase.expectedIncludeSbom, context.IncludeSbom) }) } } diff --git a/commands/audit/scarunner.go b/commands/audit/scarunner.go index 33a6e185..d7e877f0 100644 --- a/commands/audit/scarunner.go +++ b/commands/audit/scarunner.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/jfrog/jfrog-cli-security/commands/audit/sca/swift" biutils "github.com/jfrog/build-info-go/utils" @@ -107,10 +108,10 @@ func buildDepTreeAndRunScaScan(auditParallelRunner *utils.SecurityParallelRunner auditParallelRunner.ScaScansWg.Add(1) // defer auditParallelRunner.ScaScansWg.Done() _, taskErr := auditParallelRunner.Runner.AddTaskWithError(executeScaScanTask(auditParallelRunner, serverDetails, auditParams, targetResult, treeResult), func(err error) { - _ = targetResult.AddTargetError(fmt.Errorf("Failed to execute SCA scan: %s", err.Error()), auditParams.AllowPartialResults()) + _ = targetResult.AddTargetError(fmt.Errorf("failed to execute SCA scan: %s", err.Error()), auditParams.AllowPartialResults()) }) if taskErr != nil { - _ = targetResult.AddTargetError(fmt.Errorf("Failed to create SCA scan task: %s", taskErr.Error()), auditParams.AllowPartialResults()) + _ = targetResult.AddTargetError(fmt.Errorf("failed to create SCA scan task: %s", taskErr.Error()), auditParams.AllowPartialResults()) auditParallelRunner.ScaScansWg.Done() } } @@ -137,7 +138,7 @@ func executeScaScanTask(auditParallelRunner *utils.SecurityParallelRunner, serve auditParallelRunner.ResultsMu.Lock() defer auditParallelRunner.ResultsMu.Unlock() // We add the results before checking for errors, so we can display the results even if an error occurred. - scan.NewScaScanResults(sca.GetScaScansStatusCode(xrayErr, scanResults...), scanResults...).IsMultipleRootProject = clientutils.Pointer(len(treeResult.FullDepTrees) > 1) + scan.NewScaScanResults(sca.GetScaScansStatusCode(xrayErr, scanResults...), results.DepTreeToSbom(treeResult.FullDepTrees), scanResults...).IsMultipleRootProject = clientutils.Pointer(len(treeResult.FullDepTrees) > 1) addThirdPartyDependenciesToParams(auditParams, scan.Technology, treeResult.FlatTree, treeResult.FullDepTrees) if xrayErr != nil { diff --git a/commands/enrich/enrich.go b/commands/enrich/enrich.go index 823056ad..68a31f94 100644 --- a/commands/enrich/enrich.go +++ b/commands/enrich/enrich.go @@ -263,7 +263,7 @@ func (enrichCmd *EnrichCommand) createIndexerHandlerFunc(indexedFileProducer par if err != nil { return targetResults.AddTargetError(fmt.Errorf("%s failed to import graph: %s", logPrefix, err.Error()), false) } - targetResults.NewScaScanResults(sca.GetScaScansStatusCode(err, *scanResults), *scanResults) + targetResults.NewScaScanResults(sca.GetScaScansStatusCode(err, *scanResults), results.Sbom{}, *scanResults) targetResults.Technology = techutils.Technology(scanResults.ScannedPackageType) return } diff --git a/commands/git/audit/gitaudit.go b/commands/git/audit/gitaudit.go index 21d4a188..88efa759 100644 --- a/commands/git/audit/gitaudit.go +++ b/commands/git/audit/gitaudit.go @@ -85,7 +85,9 @@ func toAuditParams(params GitAuditParams) *sourceAudit.AuditParams { params.resultsContext.ProjectKey, params.source.GitRepoHttpsCloneUrl, params.resultsContext.IncludeVulnerabilities, - params.resultsContext.IncludeLicenses) + params.resultsContext.IncludeLicenses, + false, + ) auditParams.SetResultsContext(resultContext) log.Debug(fmt.Sprintf("Results context: %+v", resultContext)) // Scan params diff --git a/commands/scan/buildscan.go b/commands/scan/buildscan.go index ba3995c6..daa96807 100644 --- a/commands/scan/buildscan.go +++ b/commands/scan/buildscan.go @@ -150,7 +150,7 @@ func (bsc *BuildScanCommand) runBuildScanAndPrintResults(xrayManager *xray.XrayS SetResultsContext(results.ResultContext{ProjectKey: params.Project, IncludeVulnerabilities: bsc.includeVulnerabilities}) scanResults := cmdResults.NewScanResults(results.ScanTarget{Name: fmt.Sprintf("%s (%s)", params.BuildName, params.BuildNumber)}) - scanResults.NewScaScanResults(0, services.ScanResponse{ + scanResults.NewScaScanResults(0, results.Sbom{}, services.ScanResponse{ Violations: buildScanResults.Violations, Vulnerabilities: buildScanResults.Vulnerabilities, XrayDataUrl: buildScanResults.MoreDetailsUrl, diff --git a/commands/scan/scan.go b/commands/scan/scan.go index c7ea0227..ea6c1819 100644 --- a/commands/scan/scan.go +++ b/commands/scan/scan.go @@ -156,6 +156,11 @@ func (scanCmd *ScanCommand) SetIncludeLicenses(include bool) *ScanCommand { return scanCmd } +func (scanCmd *ScanCommand) SetIncludeSbom(include bool) *ScanCommand { + scanCmd.resultsContext.IncludeSbom = include + return scanCmd +} + func (scanCmd *ScanCommand) ServerDetails() (*config.ServerDetails, error) { return scanCmd.serverDetails, nil } @@ -459,7 +464,7 @@ func (scanCmd *ScanCommand) createIndexerHandlerFunc(file *spec.File, cmdResults if err != nil { return targetResults.AddTargetError(fmt.Errorf("%s sca scanning '%s' failed with error: %s", scanLogPrefix, graph.Id, err.Error()), false) } else { - targetResults.NewScaScanResults(sca.GetScaScansStatusCode(err, *graphScanResults), *graphScanResults) + targetResults.NewScaScanResults(sca.GetScaScansStatusCode(err, *graphScanResults), results.CompTreeToSbom(graph), *graphScanResults) targetResults.Technology = techutils.Technology(graphScanResults.ScannedPackageType) } if !cmdResults.EntitledForJas { diff --git a/jas/runner/jasrunner_test.go b/jas/runner/jasrunner_test.go index 42a7f699..7e85726b 100644 --- a/jas/runner/jasrunner_test.go +++ b/jas/runner/jasrunner_test.go @@ -46,7 +46,7 @@ func TestJasRunner(t *testing.T) { jasScanner, err := jas.CreateJasScanner(&jas.FakeServerDetails, false, "", jas.GetAnalyzerManagerXscEnvVars("", "", "", []string{}, targetResults.GetTechnologies()...)) assert.NoError(t, err) - targetResults.NewScaScanResults(0, jas.FakeBasicXrayResults[0]) + targetResults.NewScaScanResults(0, results.Sbom{}, jas.FakeBasicXrayResults[0]) testParams := JasRunnerParams{ Runner: securityParallelRunnerForTest, Scanner: jasScanner, diff --git a/utils/formats/table.go b/utils/formats/table.go index c1662698..cb3b82d8 100644 --- a/utils/formats/table.go +++ b/utils/formats/table.go @@ -7,6 +7,8 @@ package formats type ResultsTables struct { // Licenses LicensesTable []licenseTableRow + // SBOM (Software Bill of Materials) + SbomTable []SbomTableRow // Sca tables SecurityVulnerabilitiesTable []scaVulnerabilityOrViolationTableRow SecurityViolationsTable []scaVulnerabilityOrViolationTableRow @@ -54,6 +56,15 @@ type vulnerabilityScanTableRow struct { issueId string `col-name:"Issue ID" extended:"true"` } +type SbomTableRow struct { + Component string `col-name:"Component"` + Version string `col-name:"Version"` + PackageType string `col-name:"Type"` + Relation string `col-name:"Relation"` + // For sorting + Direct bool +} + type licenseTableRow struct { licenseKey string `col-name:"License"` directDependencies []directDependenciesTableRow `embed-table:"true"` diff --git a/utils/results/common.go b/utils/results/common.go index a9c987f7..5c5c56af 100644 --- a/utils/results/common.go +++ b/utils/results/common.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" "path/filepath" + "sort" "strconv" "strings" + "golang.org/x/exp/maps" + "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-security/utils" @@ -18,6 +21,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" "github.com/jfrog/jfrog-client-go/xray/services" + xrayCmdUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/owenrumney/go-sarif/v2/sarif" "golang.org/x/exp/slices" ) @@ -670,7 +674,7 @@ func shouldSkipNotApplicable(violation services.Violation, applicabilityStatus j } if len(violation.Policies) == 0 { - return false, errors.New("A violation with no policies was provided") + return false, errors.New("a violation with no policies was provided") } for _, policy := range violation.Policies { @@ -680,3 +684,98 @@ func shouldSkipNotApplicable(violation services.Violation, applicabilityStatus j } return true, nil } + +func CompTreeToSbom(graph *xrayCmdUtils.BinaryGraphNode) (sbom Sbom) { + if graph == nil { + return + } + // Recursively parse the tree + parsed := map[string]SbomEntry{} + if strings.HasSuffix(graph.Path, ".rpm") { + // For rmp package manager, root is also included in the graph + parseBinaryNode(graph, parsed, true) + } else { + for _, node := range graph.Nodes { + parseBinaryNode(node, parsed, true) + } + } + + sbom.Components = maps.Values(parsed) + return +} + +func DepTreeToSbom(fullDepTrees []*xrayCmdUtils.GraphNode) (sbom Sbom) { + if len(fullDepTrees) == 0 { + // No dependencies + return + } + parsed := map[string]SbomEntry{} + // Recursively parse the tree + for _, projectTree := range fullDepTrees { + // First node is the root (project node), skip it + for _, directNode := range projectTree.Nodes { + // First node is direct, the rest are transitive + parseNode(directNode, parsed, true) + } + } + sbom.Components = maps.Values(parsed) + return +} + +func parseNode(node *xrayCmdUtils.GraphNode, parsed map[string]SbomEntry, direct bool) { + if parsedEntry, exists := parsed[node.Id]; exists { + // Node is parsed already, if it's direct, update the flag (can be indirect from another dep, but also direct at the project level) + if direct { + parsedEntry.Direct = true + parsed[node.Id] = parsedEntry + } + return + } + // If the node is not parsed yet, parse it and its children + component, version, packageType := techutils.SplitComponentId(node.Id) + entry := SbomEntry{Component: component, Version: version, Type: packageType, Direct: direct} + parsed[node.Id] = entry + for _, child := range node.Nodes { + parseNode(child, parsed, false) + } +} + +func parseBinaryNode(node *xrayCmdUtils.BinaryGraphNode, parsed map[string]SbomEntry, direct bool) { + if parsedEntry, exists := parsed[node.Id]; exists { + // Node is parsed already, if it's direct, update the flag (can be indirect from another dep, but also direct at the project level) + if direct { + parsedEntry.Direct = true + parsed[node.Id] = parsedEntry + } + return + } + // If the node is not parsed yet, parse it and its children + component, version, packageType := techutils.SplitComponentId(node.Id) + if version != "" { + // For docker images, binary graph also contains layer information not relevant for the sbom + entry := SbomEntry{Component: component, Version: version, Type: packageType, Direct: direct} + parsed[node.Id] = entry + } + for _, child := range node.Nodes { + parseBinaryNode(child, parsed, false) + } +} + +func SortSbom(components []SbomEntry) { + sort.Slice(components, func(i, j int) bool { + if components[i].Direct == components[j].Direct { + if components[i].Component == components[j].Component { + if components[i].Version == components[j].Version { + // Last order by type + return components[i].Type < components[j].Type + } + // Third order by version + return components[i].Version < components[j].Version + } + // Second order by component + return components[i].Component < components[j].Component + } + // First order by direct components + return components[i].Direct + }) +} diff --git a/utils/results/common_test.go b/utils/results/common_test.go index 6fc8da1e..e4492560 100644 --- a/utils/results/common_test.go +++ b/utils/results/common_test.go @@ -12,6 +12,7 @@ import ( "github.com/jfrog/jfrog-cli-security/utils/jasutils" "github.com/jfrog/jfrog-cli-security/utils/severityutils" "github.com/jfrog/jfrog-client-go/xray/services" + xrayCmdUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" ) func TestViolationFailBuild(t *testing.T) { @@ -807,3 +808,262 @@ func TestShouldSkipNotApplicable(t *testing.T) { }) } } + +func TestDepTreeToSbom(t *testing.T) { + tests := []struct { + name string + depTrees []*xrayCmdUtils.GraphNode + expectedSbom Sbom + }{ + { + name: "no deps", + depTrees: []*xrayCmdUtils.GraphNode{}, + expectedSbom: Sbom{}, + }, + { + name: "one tree with one node", + depTrees: []*xrayCmdUtils.GraphNode{ + { + Id: "root", + Nodes: []*xrayCmdUtils.GraphNode{{Id: "npm://A:1.0.1"}}, + }, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + }, + }, + }, + { + name: "one tree with multiple nodes", + depTrees: []*xrayCmdUtils.GraphNode{ + { + Id: "root", + Nodes: []*xrayCmdUtils.GraphNode{ + { + Id: "npm://A:1.0.1", + Nodes: []*xrayCmdUtils.GraphNode{{Id: "npm://B:1.0.0"}, {Id: "npm://C:1.0.1"}}, + }, + { + Id: "npm://D:2.0.0", + Nodes: []*xrayCmdUtils.GraphNode{{Id: "npm://C:1.0.1"}}, + }, + { + Id: "npm://B:1.0.0", + }, + }, + }, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "B", Version: "1.0.0", Type: "npm", Direct: true, + }, + { + Component: "D", Version: "2.0.0", Type: "npm", Direct: true, + }, + { + Component: "C", Version: "1.0.1", Type: "npm", Direct: false, + }, + }, + }, + }, + { + name: "multiple trees", + depTrees: []*xrayCmdUtils.GraphNode{ + { + Id: "root", + Nodes: []*xrayCmdUtils.GraphNode{ + { + Id: "npm://A:1.0.1", + Nodes: []*xrayCmdUtils.GraphNode{{Id: "go://B:1.0.0"}}, + }, + { + Id: "npm://C:1.0.1", + }, + { + Id: "npm://D:1.0.0", + }, + }, + }, + { + Id: "root", + Nodes: []*xrayCmdUtils.GraphNode{ + { + Id: "npm://A:2.0.1", + Nodes: []*xrayCmdUtils.GraphNode{{Id: "npm://B:1.0.0"}, {Id: "npm://C:1.0.1"}, {Id: "npm://D:1.2.3"}}, + }, + }, + }, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "A", Version: "2.0.1", Type: "npm", Direct: true, + }, + { + Component: "C", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "D", Version: "1.0.0", Type: "npm", Direct: true, + }, + { + Component: "B", Version: "1.0.0", Type: "Go", Direct: false, + }, + { + Component: "B", Version: "1.0.0", Type: "npm", Direct: false, + }, + { + Component: "D", Version: "1.2.3", Type: "npm", Direct: false, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sbom := DepTreeToSbom(test.depTrees) + SortSbom(sbom.Components) + assert.Equal(t, test.expectedSbom, sbom) + }) + } +} + +func TestCompTreeToSbom(t *testing.T) { + tests := []struct { + name string + compTrees *xrayCmdUtils.BinaryGraphNode + expectedSbom Sbom + }{ + { + name: "no deps", + compTrees: &xrayCmdUtils.BinaryGraphNode{}, + expectedSbom: Sbom{Components: []SbomEntry{}}, + }, + { + name: "one tree with one node", + compTrees: &xrayCmdUtils.BinaryGraphNode{ + Id: "root", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "npm://A:1.0.1"}}, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + }, + }, + }, + { + name: "one tree rpm", + compTrees: &xrayCmdUtils.BinaryGraphNode{ + Id: "npm://root:1.0.0", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "rpm://OS-1:A:1111:1.0.1"}}, + Path: "file.rpm", + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "root", Version: "1.0.0", Type: "npm", Direct: true, + }, + { + Component: "A", Version: "1111:1.0.1", Type: "RPM", Direct: false, + }, + }, + }, + }, + { + name: "one tree with multiple nodes", + compTrees: &xrayCmdUtils.BinaryGraphNode{ + Id: "root", + Nodes: []*xrayCmdUtils.BinaryGraphNode{ + { + Id: "npm://A:1.0.1", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "npm://B:1.0.0"}, {Id: "npm://C:1.0.1"}}, + }, + { + Id: "npm://D:2.0.0", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "npm://C:1.0.1"}}, + }, + { + Id: "npm://B:1.0.0", + }, + { + Id: "npm://No-Version", + }, + }, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "B", Version: "1.0.0", Type: "npm", Direct: true, + }, + { + Component: "D", Version: "2.0.0", Type: "npm", Direct: true, + }, + { + Component: "C", Version: "1.0.1", Type: "npm", Direct: false, + }, + }, + }, + }, + { + name: "multiple trees", + compTrees: &xrayCmdUtils.BinaryGraphNode{ + Id: "root", + Nodes: []*xrayCmdUtils.BinaryGraphNode{ + { + Id: "npm://A:1.0.1", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "go://B:1.0.0"}}, + }, + { + Id: "npm://C:1.0.1", + }, + { + Id: "npm://A:2.0.1", + Nodes: []*xrayCmdUtils.BinaryGraphNode{{Id: "npm://B:1.0.0"}, {Id: "npm://C:1.0.1"}}, + }, + }, + }, + expectedSbom: Sbom{ + Components: []SbomEntry{ + { + Component: "A", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "A", Version: "2.0.1", Type: "npm", Direct: true, + }, + { + Component: "C", Version: "1.0.1", Type: "npm", Direct: true, + }, + { + Component: "B", Version: "1.0.0", Type: "Go", Direct: false, + }, + { + Component: "B", Version: "1.0.0", Type: "npm", Direct: false, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sbom := CompTreeToSbom(test.compTrees) + SortSbom(sbom.Components) + assert.Equal(t, test.expectedSbom, sbom) + }) + } +} diff --git a/utils/results/conversion/convertor.go b/utils/results/conversion/convertor.go index 4e8f9b1b..586209be 100644 --- a/utils/results/conversion/convertor.go +++ b/utils/results/conversion/convertor.go @@ -27,6 +27,8 @@ type ResultConvertParams struct { PatchBinaryPaths bool // Control if the output should include licenses information IncludeLicenses bool + // Control if the output should include SBOM information (relevant only for Table) + IncludeSbom bool // Control and override converting command results as multi target results, if nil will be determined by the results.HasMultipleTargets() IsMultipleRoots *bool // The requested scans to be included in the results, if empty all scans will be included @@ -54,6 +56,7 @@ type ResultsStreamFormatParser[T interface{}] interface { // Parse SCA content to the current scan target ParseScaIssues(target results.ScanTarget, violations bool, scaResponse results.ScanResult[services.ScanResponse], applicableScan ...results.ScanResult[[]*sarif.Run]) error ParseLicenses(target results.ScanTarget, scaResponse results.ScanResult[services.ScanResponse]) error + ParseSbom(target results.ScanTarget, sbom results.Sbom) error // Parse JAS content to the current scan target ParseSecrets(target results.ScanTarget, violations bool, secrets []results.ScanResult[[]*sarif.Run]) error ParseIacs(target results.ScanTarget, violations bool, iacs []results.ScanResult[[]*sarif.Run]) error @@ -143,6 +146,11 @@ func parseScaResults[T interface{}](params ResultConvertParams, parser ResultsSt } } } + if params.IncludeSbom { + if err = parser.ParseSbom(targetScansResults.ScanTarget, targetScansResults.ScaResults.TargetSbom); err != nil { + return + } + } return } diff --git a/utils/results/conversion/convertor_test.go b/utils/results/conversion/convertor_test.go index 19a63f09..8912176b 100644 --- a/utils/results/conversion/convertor_test.go +++ b/utils/results/conversion/convertor_test.go @@ -166,7 +166,7 @@ func getAuditTestResults(unique bool) (*results.SecurityCommandResults, validati cmdResults.SetEntitledForJas(true).SetXrayVersion("3.107.13").SetXscVersion("1.12.5").SetMultiScanId("7d5e4733-3f93-11ef-8147-e610d09d7daa") npmTargetResults := cmdResults.NewScanResults(results.ScanTarget{Target: filepath.Join("Users", "user", "project-with-issues"), Technology: techutils.Npm}).SetDescriptors(filepath.Join("Users", "user", "project-with-issues", "package.json")) // SCA scan results - npmTargetResults.NewScaScanResults(0, services.ScanResponse{ + npmTargetResults.NewScaScanResults(0, results.Sbom{}, services.ScanResponse{ ScanId: "711851ce-68c4-4dfd-7afb-c29737ebcb96", Vulnerabilities: []services.Vulnerability{ { @@ -439,7 +439,7 @@ func getDockerScanTestResults(unique bool) (*results.SecurityCommandResults, val cmdResults.SetEntitledForJas(true).SetXrayVersion("3.107.13").SetXscVersion("1.12.5").SetMultiScanId("7d5e4733-3f93-11ef-8147-e610d09d7daa") dockerImageTarget := cmdResults.NewScanResults(results.ScanTarget{Target: filepath.Join("temp", "folders", "T", "jfrog.cli.temp.-11-11", "image.tar"), Name: "platform.jfrog.io/swamp-docker/swamp:latest", Technology: techutils.Oci}) // SCA scan results - dockerImageTarget.NewScaScanResults(0, services.ScanResponse{ + dockerImageTarget.NewScaScanResults(0, results.Sbom{}, services.ScanResponse{ ScanId: "27da9106-88ea-416b-799b-bc7d15783473", Vulnerabilities: []services.Vulnerability{ { diff --git a/utils/results/conversion/sarifparser/sarifparser.go b/utils/results/conversion/sarifparser/sarifparser.go index fdf90dd3..26a55c54 100644 --- a/utils/results/conversion/sarifparser/sarifparser.go +++ b/utils/results/conversion/sarifparser/sarifparser.go @@ -227,6 +227,11 @@ func (sc *CmdResultsSarifConverter) ParseLicenses(_ results.ScanTarget, _ result return } +func (sc *CmdResultsSarifConverter) ParseSbom(target results.ScanTarget, sbom results.Sbom) (err error) { + // Not supported in Sarif format + return +} + func (sc *CmdResultsSarifConverter) ParseSecrets(target results.ScanTarget, violations bool, secrets []results.ScanResult[[]*sarif.Run]) (err error) { if err = sc.validateBeforeParse(); err != nil || !sc.entitledForJas { return diff --git a/utils/results/conversion/simplejsonparser/simplejsonparser.go b/utils/results/conversion/simplejsonparser/simplejsonparser.go index ce017315..9223966b 100644 --- a/utils/results/conversion/simplejsonparser/simplejsonparser.go +++ b/utils/results/conversion/simplejsonparser/simplejsonparser.go @@ -133,6 +133,11 @@ func (sjc *CmdResultsSimpleJsonConverter) ParseLicenses(target results.ScanTarge return } +func (sjc *CmdResultsSimpleJsonConverter) ParseSbom(target results.ScanTarget, sbom results.Sbom) (err error) { + // Not supported in the simple-json + return +} + func (sjc *CmdResultsSimpleJsonConverter) ParseSecrets(_ results.ScanTarget, isViolationsResults bool, secrets []results.ScanResult[[]*sarif.Run]) (err error) { if !sjc.entitledForJas { return diff --git a/utils/results/conversion/summaryparser/summaryparser.go b/utils/results/conversion/summaryparser/summaryparser.go index 04c8a88b..aff94ff4 100644 --- a/utils/results/conversion/summaryparser/summaryparser.go +++ b/utils/results/conversion/summaryparser/summaryparser.go @@ -254,6 +254,11 @@ func (sc *CmdResultsSummaryConverter) ParseLicenses(_ results.ScanTarget, _ resu return } +func (sc *CmdResultsSummaryConverter) ParseSbom(target results.ScanTarget, sbom results.Sbom) (err error) { + // Not supported in the summary + return +} + func (sc *CmdResultsSummaryConverter) ParseSecrets(_ results.ScanTarget, isViolationsResults bool, secrets []results.ScanResult[[]*sarif.Run]) (err error) { if !sc.entitledForJas || sc.currentScan.Vulnerabilities == nil { // JAS results are only supported as vulnerabilities for now diff --git a/utils/results/conversion/tableparser/tableparser.go b/utils/results/conversion/tableparser/tableparser.go index cc6774d3..aa09deac 100644 --- a/utils/results/conversion/tableparser/tableparser.go +++ b/utils/results/conversion/tableparser/tableparser.go @@ -2,6 +2,7 @@ package tableparser import ( "github.com/owenrumney/go-sarif/v2/sarif" + "golang.org/x/exp/maps" "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -13,12 +14,13 @@ import ( type CmdResultsTableConverter struct { simpleJsonConvertor *simplejsonparser.CmdResultsSimpleJsonConverter + sbomInfo map[string]results.SbomEntry // If supported, pretty print the output in the tables pretty bool } func NewCmdResultsTableConverter(pretty bool) *CmdResultsTableConverter { - return &CmdResultsTableConverter{pretty: pretty, simpleJsonConvertor: simplejsonparser.NewCmdResultsSimpleJsonConverter(pretty, true)} + return &CmdResultsTableConverter{pretty: pretty, simpleJsonConvertor: simplejsonparser.NewCmdResultsSimpleJsonConverter(pretty, true), sbomInfo: make(map[string]results.SbomEntry)} } func (tc *CmdResultsTableConverter) Get() (formats.ResultsTables, error) { @@ -27,7 +29,9 @@ func (tc *CmdResultsTableConverter) Get() (formats.ResultsTables, error) { return formats.ResultsTables{}, err } return formats.ResultsTables{ - LicensesTable: formats.ConvertToLicenseTableRow(simpleJsonFormat.Licenses), + LicensesTable: formats.ConvertToLicenseTableRow(simpleJsonFormat.Licenses), + SbomTable: convertToSbomTableRow(maps.Values(tc.sbomInfo)), + SecurityVulnerabilitiesTable: formats.ConvertToScaVulnerabilityOrViolationTableRow(simpleJsonFormat.Vulnerabilities), SecurityViolationsTable: formats.ConvertToScaVulnerabilityOrViolationTableRow(simpleJsonFormat.SecurityViolations), LicenseViolationsTable: formats.ConvertToLicenseViolationTableRow(simpleJsonFormat.LicensesViolations), @@ -68,3 +72,36 @@ func (tc *CmdResultsTableConverter) ParseIacs(target results.ScanTarget, isViola func (tc *CmdResultsTableConverter) ParseSast(target results.ScanTarget, isViolationsResults bool, sast []results.ScanResult[[]*sarif.Run]) (err error) { return tc.simpleJsonConvertor.ParseSast(target, isViolationsResults, sast) } + +func (tc *CmdResultsTableConverter) ParseSbom(_ results.ScanTarget, sbom results.Sbom) (err error) { + for _, entry := range sbom.Components { + if parsedEntry, exists := tc.sbomInfo[entry.String()]; exists { + if entry.Direct && !parsedEntry.Direct { + // If the entry is direct, we want to override the existing entry + tc.sbomInfo[entry.String()] = entry + } + continue + } + // If the entry does not exist, we want to add it + tc.sbomInfo[entry.String()] = entry + } + return +} + +func convertToSbomTableRow(rows []results.SbomEntry) (tableRows []formats.SbomTableRow) { + results.SortSbom(rows) + for _, entry := range rows { + relation := "Direct" + if !entry.Direct { + relation = "Transitive" + } + tableRows = append(tableRows, formats.SbomTableRow{ + Component: entry.Component, + PackageType: entry.Type, + Direct: entry.Direct, + Version: entry.Version, + Relation: relation, + }) + } + return +} diff --git a/utils/results/output/resultwriter.go b/utils/results/output/resultwriter.go index df9311f5..ff1c3618 100644 --- a/utils/results/output/resultwriter.go +++ b/utils/results/output/resultwriter.go @@ -134,6 +134,7 @@ func (rw *ResultsWriter) createResultsConvertor(pretty bool) *conversion.Command PlatformUrl: rw.platformUrl, IsMultipleRoots: rw.isMultipleRoots, IncludeLicenses: rw.commandResults.IncludesLicenses(), + IncludeSbom: rw.commandResults.IncludeSbom(), IncludeVulnerabilities: rw.commandResults.IncludesVulnerabilities(), HasViolationContext: rw.commandResults.HasViolationContext(), RequestedScans: rw.subScansPerformed, @@ -215,10 +216,15 @@ func (rw *ResultsWriter) printScaTablesIfNeeded(tableContent formats.ResultsTabl return } } - if !rw.commandResults.IncludesLicenses() { + if rw.commandResults.IncludesLicenses() { + if err = PrintLicensesTable(tableContent, rw.printExtended, rw.commandResults.CmdType); err != nil { + return + } + } + if !rw.commandResults.IncludeSbom() { return } - return PrintLicensesTable(tableContent, rw.printExtended, rw.commandResults.CmdType) + return PrintSbomTable(tableContent, rw.commandResults.CmdType) } func (rw *ResultsWriter) printJasTablesIfNeeded(tableContent formats.ResultsTables, subScan utils.SubScanType, scanType jasutils.JasScanType) (err error) { @@ -302,6 +308,12 @@ func PrintLicensesTable(tables formats.ResultsTables, printExtended bool, cmdTyp return coreutils.PrintTable(tables.LicensesTable, "Licenses", "No licenses were found", printExtended) } +func PrintSbomTable(tables formats.ResultsTables, cmdType utils.CommandType) error { + // Space before the tables + log.Output() + return coreutils.PrintTable(tables.SbomTable, "Software Bill of Materials (SBOM)", "No components were found", false) +} + func PrintJasTable(tables formats.ResultsTables, entitledForJas bool, scanType jasutils.JasScanType, violations bool) error { if !entitledForJas { return nil diff --git a/utils/results/results.go b/utils/results/results.go index 0c3f2a6f..99965ba3 100644 --- a/utils/results/results.go +++ b/utils/results/results.go @@ -56,6 +56,8 @@ type ResultContext struct { IncludeVulnerabilities bool `json:"include_vulnerabilities"` // If requested, the results will include licenses IncludeLicenses bool `json:"include_licenses"` + // If requested, the results will include sbom + IncludeSbom bool `json:"include_sbom,omitempty"` // The active watches defined on the project_key and git_repository values above that were fetched from the platform PlatformWatches *xrayApi.ResourcesWatchesBody `json:"platform_watches,omitempty"` } @@ -87,10 +89,28 @@ type ScaScanResults struct { IsMultipleRootProject *bool `json:"is_multiple_root_project,omitempty"` // Target of the scan Descriptors []string `json:"descriptors,omitempty"` + // Sbom + TargetSbom Sbom `json:"sbom,omitempty"` // Sca scan results XrayResults []ScanResult[services.ScanResponse] `json:"xray_scan,omitempty"` } +// Software Bill of Materials (SBOM) is a structured list of components in a piece of software. +type Sbom struct { + Components []SbomEntry `json:"components,omitempty"` +} +type SbomEntry struct { + Component string `json:"component"` + Version string `json:"version"` + Type string `json:"type"` + // Direct dependency or transitive dependency + Direct bool `json:"direct"` +} + +func (se SbomEntry) String() string { + return fmt.Sprintf("%s:%s (%s)", se.Component, se.Version, se.Type) +} + type JasScansResults struct { JasVulnerabilities JasScanResults `json:"jas_vulnerabilities,omitempty"` JasViolations JasScanResults `json:"jas_violations,omitempty"` @@ -197,6 +217,10 @@ func (r *SecurityCommandResults) IncludesLicenses() bool { return r.ResultContext.IncludeLicenses } +func (r *SecurityCommandResults) IncludeSbom() bool { + return r.ResultContext.IncludeSbom +} + func (r *SecurityCommandResults) GetTargetsPaths() (paths []string) { for _, scan := range r.Targets { paths = append(paths, scan.Target) @@ -411,10 +435,11 @@ func (sr *TargetResults) SetDescriptors(descriptors ...string) *TargetResults { return sr } -func (sr *TargetResults) NewScaScanResults(errorCode int, responses ...services.ScanResponse) *ScaScanResults { +func (sr *TargetResults) NewScaScanResults(errorCode int, sbom Sbom, responses ...services.ScanResponse) *ScaScanResults { if sr.ScaResults == nil { sr.ScaResults = &ScaScanResults{} } + sr.ScaResults.TargetSbom = sbom for _, response := range responses { sr.ScaResults.XrayResults = append(sr.ScaResults.XrayResults, ScanResult[services.ScanResponse]{Scan: response, StatusCode: errorCode}) } diff --git a/utils/validations/test_validate_sca.go b/utils/validations/test_validate_sca.go index 13c9deb9..bd9d1ecf 100644 --- a/utils/validations/test_validate_sca.go +++ b/utils/validations/test_validate_sca.go @@ -56,7 +56,7 @@ func ValidateScanResponseIssuesCount(t *testing.T, params ValidationParams, cont } } - ValidateTotalCount(t, "json", params.ExactResultsMatch, params.Total, vulnerabilities, violations, licenses) + ValidateTotalCount(t, "json", params.ExactResultsMatch, params.Total, vulnerabilities, violations, licenses, 0) if params.Violations != nil { ValidateScaViolationCount(t, "json", params.ExactResultsMatch, params.Violations.ValidateType, securityViolations, licenseViolations, operationalViolations) if params.Violations.ValidateApplicabilityStatus != nil || params.Violations.ValidateScan != nil { diff --git a/utils/validations/test_validation.go b/utils/validations/test_validation.go index d12c0966..a7db721e 100644 --- a/utils/validations/test_validation.go +++ b/utils/validations/test_validation.go @@ -35,6 +35,8 @@ type ValidationParams struct { Vulnerabilities *VulnerabilityCount // Validate number of violations in different contexts Violations *ViolationCount + // Validate number of components in the sbom + SbomComponents *SbomCount } type TotalCount struct { @@ -44,6 +46,8 @@ type TotalCount struct { Vulnerabilities int // Expected number of total violations (sca security + sca license + sca operational + sast + iac + secrets) Violations int + // Expected number of components in the sbom + SbomComponents int } type ScanCount struct { @@ -57,6 +61,13 @@ type ScanCount struct { Secrets int } +type SbomCount struct { + // Expected number of direct components + Direct int + // Expected number of transitive components + Transitive int +} + type VulnerabilityCount struct { // If exists, validate the total amount of issues in different scan types (SCA/SAST/SECRETS/IAC) ValidateScan *ScanCount @@ -267,7 +278,7 @@ func ValidateContent(t *testing.T, exactMatch bool, validations ...Validation) b type validationCountActualValues struct { // Total counts - Vulnerabilities, Violations, Licenses int + Vulnerabilities, Violations, Licenses, SbomComponents int // Vulnerabilities counts SastVulnerabilities, SecretsVulnerabilities, IacVulnerabilities, ScaVulnerabilities int ApplicableVulnerabilities, UndeterminedVulnerabilities, NotCoveredVulnerabilities, NotApplicableVulnerabilities, MissingContextVulnerabilities, InactiveSecretsVulnerabilities int @@ -275,15 +286,18 @@ type validationCountActualValues struct { SastViolations, SecretsViolations, IacViolations, ScaViolations int SecurityViolations, LicenseViolations, OperationalViolations int ApplicableViolations, UndeterminedViolations, NotCoveredViolations, NotApplicableViolations, MissingContextViolations, InactiveSecretsViolations int + // Sbom counts + DirectComponents, TransitiveComponents int } func ValidateCount(t *testing.T, outputType string, params ValidationParams, actual validationCountActualValues) { - ValidateTotalCount(t, outputType, params.ExactResultsMatch, params.Total, actual.Vulnerabilities, actual.Violations, actual.Licenses) + ValidateTotalCount(t, outputType, params.ExactResultsMatch, params.Total, actual.Vulnerabilities, actual.Violations, actual.Licenses, actual.SbomComponents) ValidateVulnerabilitiesCount(t, outputType, params.ExactResultsMatch, params.Vulnerabilities, actual) ValidateViolationCount(t, outputType, params.ExactResultsMatch, params.Violations, actual) + ValidateSbomComponentsCount(t, outputType, params.ExactResultsMatch, params.SbomComponents, actual.DirectComponents, actual.TransitiveComponents) } -func ValidateTotalCount(t *testing.T, outputType string, exactMatch bool, params *TotalCount, vulnerabilities, violations, license int) { +func ValidateTotalCount(t *testing.T, outputType string, exactMatch bool, params *TotalCount, vulnerabilities, violations, license, sbomComponents int) { if params == nil { return } @@ -291,6 +305,7 @@ func ValidateTotalCount(t *testing.T, outputType string, exactMatch bool, params CountValidation[int]{Expected: params.Vulnerabilities, Actual: vulnerabilities, Msg: GetValidationCountErrMsg("vulnerabilities", outputType, exactMatch, params.Vulnerabilities, vulnerabilities)}, CountValidation[int]{Expected: params.Violations, Actual: violations, Msg: GetValidationCountErrMsg("violations", outputType, exactMatch, params.Violations, violations)}, CountValidation[int]{Expected: params.Licenses, Actual: license, Msg: GetValidationCountErrMsg("licenses", outputType, exactMatch, params.Licenses, license)}, + CountValidation[int]{Expected: params.SbomComponents, Actual: sbomComponents, Msg: GetValidationCountErrMsg("sbom components", outputType, exactMatch, params.SbomComponents, sbomComponents)}, ) } @@ -355,3 +370,13 @@ func ValidateScaViolationCount(t *testing.T, outputType string, exactMatch bool, CountValidation[int]{Expected: params.Operational, Actual: operationalViolations, Msg: GetValidationCountErrMsg("operational risk violations", outputType, exactMatch, params.Operational, operationalViolations)}, ) } + +func ValidateSbomComponentsCount(t *testing.T, outputType string, exactMatch bool, params *SbomCount, directComponents, transitiveComponents int) { + if params == nil { + return + } + ValidateContent(t, exactMatch, + CountValidation[int]{Expected: params.Direct, Actual: directComponents, Msg: GetValidationCountErrMsg("direct components", outputType, exactMatch, params.Direct, directComponents)}, + CountValidation[int]{Expected: params.Transitive, Actual: transitiveComponents, Msg: GetValidationCountErrMsg("transitive components", outputType, exactMatch, params.Transitive, transitiveComponents)}, + ) +} diff --git a/utils/xsc/analyticsmetrics_test.go b/utils/xsc/analyticsmetrics_test.go index b3e3614e..3b40955e 100644 --- a/utils/xsc/analyticsmetrics_test.go +++ b/utils/xsc/analyticsmetrics_test.go @@ -207,7 +207,7 @@ func getDummyContentForGeneralEvent(withJas, withErr, withResultContext bool) *r cmdResults.StartTime = time.Now() cmdResults.MultiScanId = "msi" scanResults := cmdResults.NewScanResults(results.ScanTarget{Target: "target"}) - scanResults.NewScaScanResults(0, services.ScanResponse{Vulnerabilities: vulnerabilities}) + scanResults.NewScaScanResults(0, results.Sbom{}, services.ScanResponse{Vulnerabilities: vulnerabilities}) if withJas { scanResults.JasResults.ApplicabilityScanResults = validations.NewMockJasRuns(sarifutils.CreateRunWithDummyResults(sarifutils.CreateDummyPassingResult("applic_CVE-123")))