diff --git a/src/controller/scan/base_controller.go b/src/controller/scan/base_controller.go index a57d1f21b..a1627af30 100644 --- a/src/controller/scan/base_controller.go +++ b/src/controller/scan/base_controller.go @@ -17,6 +17,7 @@ package scan import ( "bytes" "context" + "encoding/json" "fmt" "reflect" "strings" @@ -674,12 +675,23 @@ func (bc *basicController) GetReport(ctx context.Context, artifact *ar.Artifact, return reports, nil } +func isSBOMMimeTypes(mimeTypes []string) bool { + for _, mimeType := range mimeTypes { + if mimeType == v1.MimeTypeSBOMReport { + return true + } + } + return false +} + // GetSummary ... func (bc *basicController) GetSummary(ctx context.Context, artifact *ar.Artifact, mimeTypes []string) (map[string]interface{}, error) { if artifact == nil { return nil, errors.New("no way to get report summaries for nil artifact") } - + if isSBOMMimeTypes(mimeTypes) { + return bc.GetSBOMSummary(ctx, artifact, mimeTypes) + } // Get reports first rps, err := bc.GetReport(ctx, artifact, mimeTypes) if err != nil { @@ -708,6 +720,30 @@ func (bc *basicController) GetSummary(ctx context.Context, artifact *ar.Artifact return summaries, nil } +func (bc *basicController) GetSBOMSummary(ctx context.Context, art *ar.Artifact, mimeTypes []string) (map[string]interface{}, error) { + if art == nil { + return nil, errors.New("no way to get report summaries for nil artifact") + } + r, err := bc.sc.GetRegistrationByProject(ctx, art.ProjectID) + if err != nil { + return nil, errors.Wrap(err, "scan controller: get sbom summary") + } + reports, err := bc.manager.GetBy(ctx, art.Digest, r.UUID, mimeTypes) + if err != nil { + return nil, err + } + if len(reports) == 0 { + return map[string]interface{}{}, nil + } + reportContent := reports[0].Report + if len(reportContent) == 0 { + log.Warning("no content for current report") + } + result := map[string]interface{}{} + err = json.Unmarshal([]byte(reportContent), &result) + return result, err +} + // GetScanLog ... func (bc *basicController) GetScanLog(ctx context.Context, artifact *ar.Artifact, uuid string) ([]byte, error) { if len(uuid) == 0 { diff --git a/src/controller/scan/base_controller_test.go b/src/controller/scan/base_controller_test.go index 9d9f2c334..9d485f16c 100644 --- a/src/controller/scan/base_controller_test.go +++ b/src/controller/scan/base_controller_test.go @@ -78,7 +78,7 @@ type ControllerTestSuite struct { taskMgr *tasktesting.Manager reportMgr *reporttesting.Manager ar artifact.Controller - c Controller + c *basicController reportConverter *postprocessorstesting.ScanReportV1ToV2Converter cache *mockcache.Cache } @@ -180,7 +180,19 @@ func (suite *ControllerTestSuite) SetupSuite() { }, } + sbomReport := []*scan.Report{ + { + ID: 12, + UUID: "rp-uuid-002", + Digest: "digest-code", + RegistrationUUID: "uuid001", + MimeType: "application/vnd.scanner.adapter.sbom.report.harbor+json; version=1.0", + Status: "Success", + Report: `{"sbom_digest": "sha256:1234567890", "scan_status": "Success", "duration": 3, "start_time": "2021-09-01T00:00:00Z", "end_time": "2021-09-01T00:00:03Z"}`, + }, + } mgr.On("GetBy", mock.Anything, suite.artifact.Digest, suite.registration.UUID, []string{v1.MimeTypeNativeReport}).Return(reports, nil) + mgr.On("GetBy", mock.Anything, suite.artifact.Digest, suite.registration.UUID, []string{v1.MimeTypeSBOMReport}).Return(sbomReport, nil) mgr.On("Get", mock.Anything, "rp-uuid-001").Return(reports[0], nil) mgr.On("UpdateReportData", "rp-uuid-001", suite.rawReport, (int64)(10000)).Return(nil) mgr.On("UpdateStatus", "the-uuid-123", "Success", (int64)(10000)).Return(nil) @@ -620,3 +632,26 @@ func (suite *ControllerTestSuite) makeExtraAttrs(artifactID int64, reportUUIDs . return extraAttrs } + +func (suite *ControllerTestSuite) TestGenerateSBOMSummary() { + sum, err := suite.c.GetSBOMSummary(context.TODO(), suite.artifact, []string{v1.MimeTypeSBOMReport}) + suite.Nil(err) + suite.NotNil(sum) + status := sum["scan_status"] + suite.NotNil(status) + dgst := sum["sbom_digest"] + suite.NotNil(dgst) + suite.Equal("Success", status) + suite.Equal("sha256:1234567890", dgst) +} + +func TestIsSBOMMimeTypes(t *testing.T) { + // Test with a slice containing the SBOM mime type + assert.True(t, isSBOMMimeTypes([]string{v1.MimeTypeSBOMReport})) + + // Test with a slice not containing the SBOM mime type + assert.False(t, isSBOMMimeTypes([]string{"application/vnd.oci.image.manifest.v1+json"})) + + // Test with an empty slice + assert.False(t, isSBOMMimeTypes([]string{})) +} diff --git a/src/pkg/scan/rest/v1/models.go b/src/pkg/scan/rest/v1/models.go index fc48717fb..c31edb93b 100644 --- a/src/pkg/scan/rest/v1/models.go +++ b/src/pkg/scan/rest/v1/models.go @@ -21,12 +21,11 @@ import ( "github.com/goharbor/harbor/src/lib/errors" ) -const ( - // ScanTypeVulnerability the scan type for vulnerability - ScanTypeVulnerability = "vulnerability" - // ScanTypeSbom the scan type for sbom - ScanTypeSbom = "sbom" -) +var supportedMimeTypes = []string{ + MimeTypeNativeReport, + MimeTypeGenericVulnerabilityReport, + MimeTypeSBOMReport, +} // Scanner represents metadata of a Scanner Adapter which allow Harbor to lookup a scanner capable of // scanning a given Artifact stored in its registry and making sure that it can interpret a @@ -105,7 +104,7 @@ func (md *ScannerAdapterMetadata) Validate() error { // either of v1.MimeTypeNativeReport OR v1.MimeTypeGenericVulnerabilityReport is required found = false for _, pm := range ca.ProducesMimeTypes { - if pm == MimeTypeNativeReport || pm == MimeTypeGenericVulnerabilityReport { + if isSupportedMimeType(pm) { found = true break } @@ -119,6 +118,15 @@ func (md *ScannerAdapterMetadata) Validate() error { return nil } +func isSupportedMimeType(mimeType string) bool { + for _, mt := range supportedMimeTypes { + if mt == mimeType { + return true + } + } + return false +} + // HasCapability returns true when mine type of the artifact support by the scanner func (md *ScannerAdapterMetadata) HasCapability(mimeType string) bool { for _, capability := range md.Capabilities { diff --git a/src/pkg/scan/rest/v1/models_test.go b/src/pkg/scan/rest/v1/models_test.go new file mode 100644 index 000000000..e96aa0178 --- /dev/null +++ b/src/pkg/scan/rest/v1/models_test.go @@ -0,0 +1,15 @@ +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSupportedMimeType(t *testing.T) { + // Test with a supported mime type + assert.True(t, isSupportedMimeType(MimeTypeSBOMReport), "isSupportedMimeType should return true for supported mime types") + + // Test with an unsupported mime type + assert.False(t, isSupportedMimeType("unsupported/mime-type"), "isSupportedMimeType should return false for unsupported mime types") +} diff --git a/src/pkg/scan/rest/v1/spec.go b/src/pkg/scan/rest/v1/spec.go index bb46d1c1b..a867e7167 100644 --- a/src/pkg/scan/rest/v1/spec.go +++ b/src/pkg/scan/rest/v1/spec.go @@ -39,9 +39,14 @@ const ( MimeTypeScanRequest = "application/vnd.scanner.adapter.scan.request+json; version=1.0" // MimeTypeScanResponse defines the mime type for scan response MimeTypeScanResponse = "application/vnd.scanner.adapter.scan.response+json; version=1.0" + // MimeTypeSBOMReport + MimeTypeSBOMReport = "application/vnd.security.sbom.report+json; version=1.0" // MimeTypeGenericVulnerabilityReport defines the MIME type for the generic report with enhanced information MimeTypeGenericVulnerabilityReport = "application/vnd.security.vulnerability.report; version=1.1" + ScanTypeVulnerability = "vulnerability" + ScanTypeSbom = "sbom" + apiPrefix = "/api/v1" ) diff --git a/src/server/v2.0/handler/artifact.go b/src/server/v2.0/handler/artifact.go index 84d78cc5d..dfc457047 100644 --- a/src/server/v2.0/handler/artifact.go +++ b/src/server/v2.0/handler/artifact.go @@ -107,8 +107,8 @@ func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListAr if err != nil { return a.SendError(ctx, err) } - - assembler := assembler.NewVulAssembler(lib.BoolValue(params.WithScanOverview), parseScanReportMimeTypes(params.XAcceptVulnerabilities)) + overviewOpts := model.NewOverviewOptions(model.WithSBOM(lib.BoolValue(params.WithSbomOverview)), model.WithVuln(lib.BoolValue(params.WithScanOverview))) + assembler := assembler.NewScanReportAssembler(overviewOpts, parseScanReportMimeTypes(params.XAcceptVulnerabilities)) var artifacts []*models.Artifact for _, art := range arts { artifact := &model.Artifact{} @@ -138,8 +138,9 @@ func (a *artifactAPI) GetArtifact(ctx context.Context, params operation.GetArtif } art := &model.Artifact{} art.Artifact = *artifact + overviewOpts := model.NewOverviewOptions(model.WithSBOM(lib.BoolValue(params.WithSbomOverview)), model.WithVuln(lib.BoolValue(params.WithScanOverview))) - err = assembler.NewVulAssembler(lib.BoolValue(params.WithScanOverview), parseScanReportMimeTypes(params.XAcceptVulnerabilities)).WithArtifacts(art).Assemble(ctx) + err = assembler.NewScanReportAssembler(overviewOpts, parseScanReportMimeTypes(params.XAcceptVulnerabilities)).WithArtifacts(art).Assemble(ctx) if err != nil { log.Warningf("failed to assemble vulnerabilities with artifact, error: %v", err) } diff --git a/src/server/v2.0/handler/assembler/vul.go b/src/server/v2.0/handler/assembler/report.go similarity index 55% rename from src/server/v2.0/handler/assembler/vul.go rename to src/server/v2.0/handler/assembler/report.go index 055baab35..e4f9657ea 100644 --- a/src/server/v2.0/handler/assembler/vul.go +++ b/src/server/v2.0/handler/assembler/report.go @@ -20,43 +20,48 @@ import ( "github.com/goharbor/harbor/src/controller/scan" "github.com/goharbor/harbor/src/lib" "github.com/goharbor/harbor/src/lib/log" + v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1" "github.com/goharbor/harbor/src/server/v2.0/handler/model" ) const ( vulnerabilitiesAddition = "vulnerabilities" + startTime = "start_time" + endTime = "end_time" + scanStatus = "scan_status" + sbomDigest = "sbom_digest" + duration = "duration" ) -// NewVulAssembler returns vul assembler -func NewVulAssembler(withScanOverview bool, mimeTypes []string) *VulAssembler { - return &VulAssembler{ - scanChecker: scan.NewChecker(), - scanCtl: scan.DefaultController, - - withScanOverview: withScanOverview, - mimeTypes: mimeTypes, +// NewScanReportAssembler returns vul assembler +func NewScanReportAssembler(option *model.OverviewOptions, mimeTypes []string) *ScanReportAssembler { + return &ScanReportAssembler{ + overviewOption: option, + scanChecker: scan.NewChecker(), + scanCtl: scan.DefaultController, + mimeTypes: mimeTypes, } } -// VulAssembler vul assembler -type VulAssembler struct { +// ScanReportAssembler vul assembler +type ScanReportAssembler struct { scanChecker scan.Checker scanCtl scan.Controller - artifacts []*model.Artifact - withScanOverview bool - mimeTypes []string + artifacts []*model.Artifact + mimeTypes []string + overviewOption *model.OverviewOptions } // WithArtifacts set artifacts for the assembler -func (assembler *VulAssembler) WithArtifacts(artifacts ...*model.Artifact) *VulAssembler { +func (assembler *ScanReportAssembler) WithArtifacts(artifacts ...*model.Artifact) *ScanReportAssembler { assembler.artifacts = artifacts return assembler } // Assemble assemble vul for the artifacts -func (assembler *VulAssembler) Assemble(ctx context.Context) error { +func (assembler *ScanReportAssembler) Assemble(ctx context.Context) error { version := lib.GetAPIVersion(ctx) for _, artifact := range assembler.artifacts { @@ -72,7 +77,7 @@ func (assembler *VulAssembler) Assemble(ctx context.Context) error { artifact.SetAdditionLink(vulnerabilitiesAddition, version) - if assembler.withScanOverview { + if assembler.overviewOption.WithVuln { for _, mimeType := range assembler.mimeTypes { overview, err := assembler.scanCtl.GetSummary(ctx, &artifact.Artifact, []string{mimeType}) if err != nil { @@ -83,6 +88,20 @@ func (assembler *VulAssembler) Assemble(ctx context.Context) error { } } } + if assembler.overviewOption.WithSBOM { + overview, err := assembler.scanCtl.GetSummary(ctx, &artifact.Artifact, []string{v1.MimeTypeSBOMReport}) + if err != nil { + log.Warningf("get scan summary of artifact %s@%s for %s failed, error:%v", artifact.RepositoryName, artifact.Digest, v1.MimeTypeSBOMReport, err) + } else if len(overview) > 0 { + artifact.SBOMOverView = map[string]interface{}{ + startTime: overview[startTime], + endTime: overview[endTime], + scanStatus: overview[scanStatus], + sbomDigest: overview[sbomDigest], + duration: overview[duration], + } + } + } } return nil diff --git a/src/server/v2.0/handler/assembler/vul_test.go b/src/server/v2.0/handler/assembler/report_test.go similarity index 61% rename from src/server/v2.0/handler/assembler/vul_test.go rename to src/server/v2.0/handler/assembler/report_test.go index 202720afa..6079a1d60 100644 --- a/src/server/v2.0/handler/assembler/vul_test.go +++ b/src/server/v2.0/handler/assembler/report_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/suite" + v1sq "github.com/goharbor/harbor/src/pkg/scan/rest/v1" "github.com/goharbor/harbor/src/server/v2.0/handler/model" "github.com/goharbor/harbor/src/testing/controller/scan" "github.com/goharbor/harbor/src/testing/mock" @@ -33,11 +34,11 @@ func (suite *VulAssemblerTestSuite) TestScannable() { checker := &scan.Checker{} scanCtl := &scan.Controller{} - assembler := VulAssembler{ - scanChecker: checker, - scanCtl: scanCtl, - withScanOverview: true, - mimeTypes: []string{"mimeType"}, + assembler := ScanReportAssembler{ + scanChecker: checker, + scanCtl: scanCtl, + overviewOption: model.NewOverviewOptions(model.WithVuln(true)), + mimeTypes: []string{"mimeType"}, } mock.OnAnything(checker, "IsScannable").Return(true, nil) @@ -56,10 +57,10 @@ func (suite *VulAssemblerTestSuite) TestNotScannable() { checker := &scan.Checker{} scanCtl := &scan.Controller{} - assembler := VulAssembler{ - scanChecker: checker, - scanCtl: scanCtl, - withScanOverview: true, + assembler := ScanReportAssembler{ + scanChecker: checker, + scanCtl: scanCtl, + overviewOption: model.NewOverviewOptions(model.WithVuln(true)), } mock.OnAnything(checker, "IsScannable").Return(false, nil) @@ -74,6 +75,32 @@ func (suite *VulAssemblerTestSuite) TestNotScannable() { scanCtl.AssertNotCalled(suite.T(), "GetSummary") } +func (suite *VulAssemblerTestSuite) TestAssembleSBOMOverview() { + checker := &scan.Checker{} + scanCtl := &scan.Controller{} + + assembler := ScanReportAssembler{ + scanChecker: checker, + scanCtl: scanCtl, + overviewOption: model.NewOverviewOptions(model.WithSBOM(true)), + mimeTypes: []string{v1sq.MimeTypeSBOMReport}, + } + + mock.OnAnything(checker, "IsScannable").Return(true, nil) + overview := map[string]interface{}{ + "sbom_digest": "sha256:123456", + "scan_status": "Success", + } + mock.OnAnything(scanCtl, "GetSummary").Return(overview, nil) + + var artifact model.Artifact + err := assembler.WithArtifacts(&artifact).Assemble(context.TODO()) + suite.Nil(err) + suite.Equal(artifact.SBOMOverView["sbom_digest"], "sha256:123456") + suite.Equal(artifact.SBOMOverView["scan_status"], "Success") + +} + func TestVulAssemblerTestSuite(t *testing.T) { suite.Run(t, &VulAssemblerTestSuite{}) } diff --git a/src/server/v2.0/handler/model/artifact.go b/src/server/v2.0/handler/model/artifact.go index 3627d9ced..f931c0abf 100644 --- a/src/server/v2.0/handler/model/artifact.go +++ b/src/server/v2.0/handler/model/artifact.go @@ -28,7 +28,9 @@ import ( // Artifact model type Artifact struct { artifact.Artifact + // TODO: rename to VulOverview ScanOverview map[string]interface{} `json:"scan_overview"` + SBOMOverView map[string]interface{} `json:"sbom_overview"` } // ToSwagger converts the artifact to the swagger model @@ -84,6 +86,18 @@ func (a *Artifact) ToSwagger() *models.Artifact { art.ScanOverview[key] = summary } } + if len(a.SBOMOverView) > 0 { + js, err := json.Marshal(a.SBOMOverView) + if err != nil { + log.Warningf("convert sbom summary failed, error: %v", err) + } + sbomOverview := &models.SBOMOverview{} + err = json.Unmarshal(js, sbomOverview) + if err != nil { + log.Warningf("failed to get sbom summary: error: %v", err) + } + art.SbomOverview = sbomOverview + } return art } diff --git a/src/server/v2.0/handler/model/option.go b/src/server/v2.0/handler/model/option.go new file mode 100644 index 000000000..06aab9943 --- /dev/null +++ b/src/server/v2.0/handler/model/option.go @@ -0,0 +1,47 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +// OverviewOptions define the option to query overview info +type OverviewOptions struct { + WithVuln bool + WithSBOM bool +} + +// Option define the func to build options +type Option func(*OverviewOptions) + +// NewOverviewOptions create a new OverviewOptions +func NewOverviewOptions(options ...Option) *OverviewOptions { + opts := &OverviewOptions{} + for _, f := range options { + f(opts) + } + return opts +} + +// WithVuln set the option to query vulnerability info +func WithVuln(enable bool) Option { + return func(o *OverviewOptions) { + o.WithVuln = enable + } +} + +// WithSBOM set the option to query SBOM info +func WithSBOM(enable bool) Option { + return func(o *OverviewOptions) { + o.WithSBOM = enable + } +} diff --git a/src/server/v2.0/handler/model/option_test.go b/src/server/v2.0/handler/model/option_test.go new file mode 100644 index 000000000..1b1bf2f37 --- /dev/null +++ b/src/server/v2.0/handler/model/option_test.go @@ -0,0 +1,33 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOverviewOptions(t *testing.T) { + // Test NewOverviewOptions with WithVuln and WithSBOM + opts := NewOverviewOptions(WithVuln(true), WithSBOM(true)) + assert.True(t, opts.WithVuln) + assert.True(t, opts.WithSBOM) + + // Test NewOverviewOptions with WithVuln and WithSBOM set to false + opts = NewOverviewOptions(WithVuln(false), WithSBOM(false)) + assert.False(t, opts.WithVuln) + assert.False(t, opts.WithSBOM) +}