Add generate SBOM feature (#20251)

* Add SBOM scan feature

  Add scan handler for sbom
  Delete previous sbom accessory before the job service

Signed-off-by: stonezdj <daojunz@vmware.com>

* fix issue

Signed-off-by: stonezdj <stone.zhang@broadcom.com>

---------

Signed-off-by: stonezdj <daojunz@vmware.com>
Signed-off-by: stonezdj <stone.zhang@broadcom.com>
Co-authored-by: stonezdj <daojunz@vmware.com>
This commit is contained in:
stonezdj(Daojun Zhang) 2024-04-16 21:34:19 +08:00 committed by GitHub
parent 67c03ddc4f
commit 654aa8edcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 697 additions and 69 deletions

View File

@ -996,7 +996,7 @@ paths:
description: Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response
type: boolean
required: false
default: false
default: false
- name: with_signature
in: query
description: Specify whether the signature is included inside the tags of the returning artifacts. Only works when setting "with_tag=true"
@ -1179,7 +1179,7 @@ paths:
- name: scan_request_type
in: body
required: false
schema:
schema:
$ref: '#/definitions/ScanRequestType'
responses:
'202':
@ -6769,7 +6769,7 @@ definitions:
ScanRequestType:
type: object
properties:
scan_type:
scan_type:
type: string
description: 'The scan type for the scan request. Two options are currently supported, vulnerability and sbom'
enum: [vulnerability, sbom]
@ -6797,12 +6797,12 @@ definitions:
description: 'The status of the generating SBOM task'
sbom_digest:
type: string
description: 'The digest of the generated SBOM accessory'
description: 'The digest of the generated SBOM accessory'
report_id:
type: string
description: 'id of the native scan report'
example: '5f62c830-f996-11e9-957f-0242c0a89008'
duration:
example: '5f62c830-f996-11e9-957f-0242c0a89008'
duration:
type: integer
format: int64
description: 'Time in seconds required to create the report'
@ -8437,7 +8437,7 @@ definitions:
description: Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom.
additionalProperties: True
example: {"support_vulnerability": true, "support_sbom": true}
ScannerRegistrationReq:
type: object
required:
@ -9986,7 +9986,6 @@ definitions:
items:
type: string
description: Links of the vulnerability
ScanType:
type: object
properties:

View File

@ -51,6 +51,7 @@ const (
ResourceRobot = Resource("robot")
ResourceNotificationPolicy = Resource("notification-policy")
ResourceScan = Resource("scan")
ResourceSBOM = Resource("sbom")
ResourceScanner = Resource("scanner")
ResourceArtifact = Resource("artifact")
ResourceTag = Resource("tag")
@ -182,6 +183,10 @@ var (
{Resource: ResourceScan, Action: ActionRead},
{Resource: ResourceScan, Action: ActionStop},
{Resource: ResourceSBOM, Action: ActionCreate},
{Resource: ResourceSBOM, Action: ActionStop},
{Resource: ResourceSBOM, Action: ActionRead},
{Resource: ResourceTag, Action: ActionCreate},
{Resource: ResourceTag, Action: ActionList},
{Resource: ResourceTag, Action: ActionDelete},

View File

@ -86,6 +86,9 @@ var (
{Resource: rbac.ResourceScan, Action: rbac.ActionCreate},
{Resource: rbac.ResourceScan, Action: rbac.ActionRead},
{Resource: rbac.ResourceScan, Action: rbac.ActionStop},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionStop},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionCreate},
@ -169,6 +172,9 @@ var (
{Resource: rbac.ResourceScan, Action: rbac.ActionCreate},
{Resource: rbac.ResourceScan, Action: rbac.ActionRead},
{Resource: rbac.ResourceScan, Action: rbac.ActionStop},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionCreate},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionStop},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionRead},
@ -223,6 +229,7 @@ var (
{Resource: rbac.ResourceRobot, Action: rbac.ActionList},
{Resource: rbac.ResourceScan, Action: rbac.ActionRead},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionRead},
@ -267,6 +274,7 @@ var (
{Resource: rbac.ResourceRobot, Action: rbac.ActionList},
{Resource: rbac.ResourceScan, Action: rbac.ActionRead},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionRead},
@ -290,6 +298,7 @@ var (
{Resource: rbac.ResourceConfiguration, Action: rbac.ActionRead},
{Resource: rbac.ResourceScan, Action: rbac.ActionRead},
{Resource: rbac.ResourceSBOM, Action: rbac.ActionRead},
{Resource: rbac.ResourceScanner, Action: rbac.ActionRead},

View File

@ -29,6 +29,7 @@ import (
"github.com/goharbor/harbor/src/controller/artifact/processor/chart"
"github.com/goharbor/harbor/src/controller/artifact/processor/cnab"
"github.com/goharbor/harbor/src/controller/artifact/processor/image"
"github.com/goharbor/harbor/src/controller/artifact/processor/sbom"
"github.com/goharbor/harbor/src/controller/artifact/processor/wasm"
"github.com/goharbor/harbor/src/controller/event/metadata"
"github.com/goharbor/harbor/src/controller/tag"
@ -73,6 +74,7 @@ var (
chart.ArtifactTypeChart: icon.DigestOfIconChart,
cnab.ArtifactTypeCNAB: icon.DigestOfIconCNAB,
wasm.ArtifactTypeWASM: icon.DigestOfIconWASM,
sbom.ArtifactTypeSBOM: icon.DigestOfIconAccSBOM,
}
)

View File

@ -29,8 +29,8 @@ import (
)
const (
// processorArtifactTypeSBOM is the artifact type for SBOM, it's scope is only used in the processor
processorArtifactTypeSBOM = "SBOM"
// ArtifactTypeSBOM is the artifact type for SBOM, it's scope is only used in the processor
ArtifactTypeSBOM = "SBOM"
// processorMediaType is the media type for SBOM, it's scope is only used to register the processor
processorMediaType = "application/vnd.goharbor.harbor.sbom.v1"
)
@ -85,5 +85,5 @@ func (m *Processor) AbstractAddition(_ context.Context, art *artifact.Artifact,
// GetArtifactType the artifact type is used to display the artifact type in the UI
func (m *Processor) GetArtifactType(_ context.Context, _ *artifact.Artifact) string {
return processorArtifactTypeSBOM
return ArtifactTypeSBOM
}

View File

@ -158,7 +158,7 @@ func (suite *SBOMProcessorTestSuite) TestAbstractAdditionPullManifestError() {
}
func (suite *SBOMProcessorTestSuite) TestGetArtifactType() {
suite.Equal(processorArtifactTypeSBOM, suite.processor.GetArtifactType(context.Background(), &artifact.Artifact{}))
suite.Equal(ArtifactTypeSBOM, suite.processor.GetArtifactType(context.Background(), &artifact.Artifact{}))
}
func TestSBOMProcessorTestSuite(t *testing.T) {

View File

@ -49,8 +49,10 @@ import (
"github.com/goharbor/harbor/src/pkg/scan/postprocessors"
"github.com/goharbor/harbor/src/pkg/scan/report"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
sbomModel "github.com/goharbor/harbor/src/pkg/scan/sbom/model"
"github.com/goharbor/harbor/src/pkg/scan/vuln"
"github.com/goharbor/harbor/src/pkg/task"
"github.com/goharbor/harbor/src/testing/controller/artifact"
)
var (
@ -108,6 +110,8 @@ type basicController struct {
rc robot.Controller
// Tag controller
tagCtl tag.Controller
// Artifact controller
artCtl artifact.Controller
// UUID generator
uuid uuidGenerator
// Configuration getter func
@ -259,7 +263,7 @@ func (bc *basicController) Scan(ctx context.Context, artifact *ar.Artifact, opti
launchScanJobParams []*launchScanJobParam
)
for _, art := range artifacts {
reports, err := bc.makeReportPlaceholder(ctx, r, art)
reports, err := bc.makeReportPlaceholder(ctx, r, art, opts)
if err != nil {
if errors.IsConflictErr(err) {
errs = append(errs, err)
@ -326,7 +330,7 @@ func (bc *basicController) Scan(ctx context.Context, artifact *ar.Artifact, opti
for _, launchScanJobParam := range launchScanJobParams {
launchScanJobParam.ExecutionID = opts.ExecutionID
if err := bc.launchScanJob(ctx, launchScanJobParam); err != nil {
if err := bc.launchScanJob(ctx, launchScanJobParam, opts); err != nil {
log.G(ctx).Warningf("scan artifact %s@%s failed, error: %v", artifact.RepositoryName, artifact.Digest, err)
errs = append(errs, err)
}
@ -546,13 +550,15 @@ func (bc *basicController) startScanAll(ctx context.Context, executionID int64)
return nil
}
func (bc *basicController) makeReportPlaceholder(ctx context.Context, r *scanner.Registration, art *ar.Artifact) ([]*scan.Report, error) {
mimeTypes := r.GetProducesMimeTypes(art.ManifestMediaType)
func (bc *basicController) makeReportPlaceholder(ctx context.Context, r *scanner.Registration, art *ar.Artifact, opts *Options) ([]*scan.Report, error) {
mimeTypes := r.GetProducesMimeTypes(art.ManifestMediaType, opts.GetScanType())
oldReports, err := bc.manager.GetBy(bc.cloneCtx(ctx), art.Digest, r.UUID, mimeTypes)
if err != nil {
return nil, err
}
if err := bc.deleteArtifactAccessories(ctx, oldReports); err != nil {
return nil, err
}
if err := bc.assembleReports(ctx, oldReports...); err != nil {
return nil, err
@ -574,7 +580,7 @@ func (bc *basicController) makeReportPlaceholder(ctx context.Context, r *scanner
var reports []*scan.Report
for _, pm := range r.GetProducesMimeTypes(art.ManifestMediaType) {
for _, pm := range r.GetProducesMimeTypes(art.ManifestMediaType, opts.GetScanType()) {
report := &scan.Report{
Digest: art.Digest,
RegistrationUUID: r.UUID,
@ -991,7 +997,7 @@ func (bc *basicController) makeRobotAccount(ctx context.Context, projectID int64
}
// launchScanJob launches a job to run scan
func (bc *basicController) launchScanJob(ctx context.Context, param *launchScanJobParam) error {
func (bc *basicController) launchScanJob(ctx context.Context, param *launchScanJobParam, opts *Options) error {
// don't launch scan job for the artifact which is not supported by the scanner
if !hasCapability(param.Registration, param.Artifact) {
return nil
@ -1032,6 +1038,11 @@ func (bc *basicController) launchScanJob(ctx context.Context, param *launchScanJ
MimeType: param.Artifact.ManifestMediaType,
Size: param.Artifact.Size,
},
RequestType: []*v1.ScanType{
{
Type: opts.GetScanType(),
},
},
}
rJSON, err := param.Registration.ToJSON()
@ -1265,3 +1276,48 @@ func parseOptions(options ...Option) (*Options, error) {
return ops, nil
}
// deleteArtifactAccessories delete the accessory in reports, only delete sbom accessory
func (bc *basicController) deleteArtifactAccessories(ctx context.Context, reports []*scan.Report) error {
for _, rpt := range reports {
if rpt.MimeType != v1.MimeTypeSBOMReport {
continue
}
if err := bc.deleteArtifactAccessory(ctx, rpt.Report); err != nil {
return err
}
}
return nil
}
// deleteArtifactAccessory check if current report has accessory info, if there is, delete it
func (bc *basicController) deleteArtifactAccessory(ctx context.Context, report string) error {
if len(report) == 0 {
return nil
}
sbomSummary := sbomModel.Summary{}
if err := json.Unmarshal([]byte(report), &sbomSummary); err != nil {
// it could be a non sbom report, just skip
log.Debugf("fail to unmarshal %v, skip to delete sbom report", err)
return nil
}
repo, dgst := sbomSummary.SBOMAccArt()
if len(repo) == 0 || len(dgst) == 0 {
return nil
}
art, err := bc.ar.GetByReference(ctx, repo, dgst, nil)
if err != nil {
if errors.IsNotFoundErr(err) {
return nil
}
return err
}
if art == nil {
return nil
}
err = bc.ar.Delete(ctx, art.ID)
if errors.IsNotFoundErr(err) {
return nil
}
return err
}

View File

@ -108,6 +108,7 @@ func (suite *ControllerTestSuite) SetupSuite() {
Version: "0.1.0",
},
Capabilities: []*v1.ScannerCapability{{
Type: v1.ScanTypeVulnerability,
ConsumesMimeTypes: []string{
v1.MimeTypeOCIArtifact,
v1.MimeTypeDockerArtifact,
@ -115,7 +116,17 @@ func (suite *ControllerTestSuite) SetupSuite() {
ProducesMimeTypes: []string{
v1.MimeTypeNativeReport,
},
}},
},
{
Type: v1.ScanTypeSbom,
ConsumesMimeTypes: []string{
v1.MimeTypeOCIArtifact,
},
ProducesMimeTypes: []string{
v1.MimeTypeSBOMReport,
},
},
},
Properties: v1.ScannerProperties{
"extra": "testing",
},
@ -655,3 +666,22 @@ func TestIsSBOMMimeTypes(t *testing.T) {
// Test with an empty slice
assert.False(t, isSBOMMimeTypes([]string{}))
}
func (suite *ControllerTestSuite) TestDeleteArtifactAccessories() {
// artifact not provided
suite.Nil(suite.c.deleteArtifactAccessories(context.TODO(), nil))
// artifact is provided
art := &artifact.Artifact{Artifact: art.Artifact{ID: 1, ProjectID: 1, RepositoryName: "library/photon"}}
mock.OnAnything(suite.ar, "GetByReference").Return(art, nil).Once()
mock.OnAnything(suite.ar, "Delete").Return(nil).Once()
reportContent := `{"sbom_digest":"sha256:12345", "scan_status":"Success", "duration":3, "sbom_repository":"library/photon"}`
emptyReportContent := ``
reports := []*scan.Report{
{Report: reportContent},
{Report: emptyReportContent},
}
ctx := orm.NewContext(nil, &ormtesting.FakeOrmer{})
suite.NoError(suite.c.deleteArtifactAccessories(ctx, reports))
}

View File

@ -79,7 +79,11 @@ func (bc *basicController) ListRegistrations(ctx context.Context, query *q.Query
if err != nil {
return nil, errors.Wrap(err, "api controller: list registrations")
}
for _, r := range l {
if err := bc.appendCap(ctx, r); err != nil {
return nil, err
}
}
return l, nil
}
@ -122,10 +126,25 @@ func (bc *basicController) GetRegistration(ctx context.Context, registrationUUID
if err != nil {
return nil, errors.Wrap(err, "api controller: get registration")
}
if r == nil {
return nil, nil
}
if err := bc.appendCap(ctx, r); err != nil {
return nil, err
}
return r, nil
}
func (bc *basicController) appendCap(ctx context.Context, r *scanner.Registration) error {
mt, err := bc.Ping(ctx, r)
if err != nil {
logger.Errorf("Get registration error: %s", err)
return err
}
r.Capabilities = mt.ConvertCapability()
return nil
}
// RegistrationExists ...
func (bc *basicController) RegistrationExists(ctx context.Context, registrationUUID string) bool {
registration, err := bc.manager.Get(ctx, registrationUUID)

View File

@ -70,6 +70,7 @@ import (
"github.com/goharbor/harbor/src/pkg/oidc"
"github.com/goharbor/harbor/src/pkg/scan"
"github.com/goharbor/harbor/src/pkg/scan/dao/scanner"
_ "github.com/goharbor/harbor/src/pkg/scan/sbom"
_ "github.com/goharbor/harbor/src/pkg/scan/vulnerability"
pkguser "github.com/goharbor/harbor/src/pkg/user"
"github.com/goharbor/harbor/src/pkg/version"

View File

@ -36,6 +36,7 @@ import (
_ "github.com/goharbor/harbor/src/pkg/accessory/model/subject"
_ "github.com/goharbor/harbor/src/pkg/config/inmemory"
_ "github.com/goharbor/harbor/src/pkg/config/rest"
_ "github.com/goharbor/harbor/src/pkg/scan/sbom"
_ "github.com/goharbor/harbor/src/pkg/scan/vulnerability"
)

View File

@ -66,8 +66,9 @@ type Registration struct {
Metadata *v1.ScannerAdapterMetadata `orm:"-" json:"-"`
// Timestamps
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
Capabilities map[string]interface{} `orm:"-" json:"capabilities,omitempty"`
}
// TableName for Endpoint
@ -151,15 +152,20 @@ func (r *Registration) HasCapability(manifestMimeType string) bool {
}
// GetProducesMimeTypes returns produces mime types for the artifact
func (r *Registration) GetProducesMimeTypes(mimeType string) []string {
func (r *Registration) GetProducesMimeTypes(mimeType string, scanType string) []string {
if r.Metadata == nil {
return nil
}
for _, capability := range r.Metadata.Capabilities {
for _, mt := range capability.ConsumesMimeTypes {
if mt == mimeType {
return capability.ProducesMimeTypes
capType := capability.Type
if len(capType) == 0 {
capType = v1.ScanTypeVulnerability
}
if scanType == capType {
for _, mt := range capability.ConsumesMimeTypes {
if mt == mimeType {
return capability.ProducesMimeTypes
}
}
}
}

View File

@ -38,7 +38,14 @@ func GetScanHandler(requestType string) Handler {
// Handler handler for scan job, it could be implement by different scan type, such as vulnerability, sbom
type Handler interface {
// RequestProducesMineTypes returns the produces mime types
RequestProducesMineTypes() []string
// RequiredPermissions defines the permission used by the scan robot account
RequiredPermissions() []*types.Policy
// RequestParameters defines the parameters for scan request
RequestParameters() map[string]interface{}
// ReportURLParameter defines the parameters for scan report
ReportURLParameter(sr *v1.ScanRequest) (string, error)
// PostScan defines the operation after scan
PostScan(ctx job.Context, sr *v1.ScanRequest, rp *scan.Report, rawReport string, startTime time.Time, robot *model.Robot) (string, error)
}

View File

@ -242,7 +242,13 @@ func (j *Job) Run(ctx job.Context, params job.Parameters) error {
}
myLogger.Debugf("check scan report for mime %s at %s", m, t.Format("2006/01/02 15:04:05"))
rawReport, err := fetchScanReportFromScanner(client, resp.ID, m)
reportURLParameter, err := handler.ReportURLParameter(req)
if err != nil {
errs[i] = errors.Wrap(err, "scan job: get report url")
return
}
rawReport, err := fetchScanReportFromScanner(client, resp.ID, m, reportURLParameter)
if err != nil {
// Not ready yet
if notReadyErr, ok := err.(*v1.ReportNotReadyError); ok {
@ -332,13 +338,13 @@ func getReportPlaceholder(ctx context.Context, digest string, reportUUID string,
return reports[0], nil
}
func fetchScanReportFromScanner(client v1.Client, requestID string, m string) (rawReport string, err error) {
rawReport, err = client.GetScanReport(requestID, m)
func fetchScanReportFromScanner(client v1.Client, requestID string, mimType string, urlParameter string) (rawReport string, err error) {
rawReport, err = client.GetScanReport(requestID, mimType, urlParameter)
if err != nil {
return "", err
}
// Make sure the data is aligned with the v1 spec.
if _, err = report.ResolveData(m, []byte(rawReport)); err != nil {
if _, err = report.ResolveData(mimType, []byte(rawReport)); err != nil {
return "", err
}
return rawReport, nil
@ -367,7 +373,20 @@ func ExtractScanReq(params job.Parameters) (*v1.ScanRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
reqType := v1.ScanTypeVulnerability
// attach the request with ProducesMimeTypes and Parameters
if len(req.RequestType) > 0 {
// current only support requestType with one element for each request
if len(req.RequestType[0].Type) > 0 {
reqType = req.RequestType[0].Type
}
handler := GetScanHandler(reqType)
if handler == nil {
return nil, errors.Errorf("failed to get scan handler, request type %v", reqType)
}
req.RequestType[0].ProducesMimeTypes = handler.RequestProducesMineTypes()
req.RequestType[0].Parameters = handler.RequestParameters()
}
return req, nil
}

View File

@ -211,8 +211,9 @@ func (suite *JobTestSuite) TestfetchScanReportFromScanner() {
suite.reportIDs = append(suite.reportIDs, rptID)
require.NoError(suite.T(), err)
client := &v1testing.Client{}
client.On("GetScanReport", mock.Anything, v1.MimeTypeGenericVulnerabilityReport).Return(rawContent, nil)
rawRept, err := fetchScanReportFromScanner(client, "abc", v1.MimeTypeGenericVulnerabilityReport)
client.On("GetScanReport", mock.Anything, v1.MimeTypeGenericVulnerabilityReport, mock.Anything).Return(rawContent, nil)
parameters := "sbom_media_type=application/spdx+json"
rawRept, err := fetchScanReportFromScanner(client, "abc", v1.MimeTypeGenericVulnerabilityReport, parameters)
require.NoError(suite.T(), err)
require.Equal(suite.T(), rawContent, rawRept)
}

View File

@ -68,7 +68,7 @@ type Client interface {
// Returns:
// string : the scan report of the given artifact
// error : non nil error if any errors occurred
GetScanReport(scanRequestID, reportMIMEType string) (string, error)
GetScanReport(scanRequestID, reportMIMEType string, urlParameter string) (string, error)
}
// basicClient is default implementation of the Client interface
@ -97,7 +97,7 @@ func NewClient(url, authType, accessCredential string, skipCertVerify bool) (Cli
httpClient: &http.Client{
Timeout: time.Second * 5,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
@ -167,7 +167,7 @@ func (c *basicClient) SubmitScan(req *ScanRequest) (*ScanResponse, error) {
}
// GetScanReport ...
func (c *basicClient) GetScanReport(scanRequestID, reportMIMEType string) (string, error) {
func (c *basicClient) GetScanReport(scanRequestID, reportMIMEType string, urlParameter string) (string, error) {
if len(scanRequestID) == 0 {
return "", errors.New("empty scan request ID")
}
@ -177,8 +177,11 @@ func (c *basicClient) GetScanReport(scanRequestID, reportMIMEType string) (strin
}
def := c.spec.GetScanReport(scanRequestID, reportMIMEType)
req, err := http.NewRequest(http.MethodGet, def.URL, nil)
reportURL := def.URL
if len(urlParameter) > 0 {
reportURL = fmt.Sprintf("%s?%s", def.URL, urlParameter)
}
req, err := http.NewRequest(http.MethodGet, reportURL, nil)
if err != nil {
return "", errors.Wrap(err, "v1 client: get scan report")
}

View File

@ -72,7 +72,7 @@ func (suite *ClientTestSuite) TestClientSubmitScan() {
// TestClientGetScanReportError tests getting report failed
func (suite *ClientTestSuite) TestClientGetScanReportError() {
_, err := suite.client.GetScanReport("id1", MimeTypeNativeReport)
_, err := suite.client.GetScanReport("id1", MimeTypeNativeReport, "")
require.Error(suite.T(), err)
assert.Condition(suite.T(), func() (success bool) {
success = strings.Index(err.Error(), "error") != -1
@ -82,14 +82,14 @@ func (suite *ClientTestSuite) TestClientGetScanReportError() {
// TestClientGetScanReport tests getting report
func (suite *ClientTestSuite) TestClientGetScanReport() {
res, err := suite.client.GetScanReport("id2", MimeTypeNativeReport)
res, err := suite.client.GetScanReport("id2", MimeTypeNativeReport, "")
require.NoError(suite.T(), err)
require.NotEmpty(suite.T(), res)
}
// TestClientGetScanReportNotReady tests the case that the report is not ready
func (suite *ClientTestSuite) TestClientGetScanReportNotReady() {
_, err := suite.client.GetScanReport("id3", MimeTypeNativeReport)
_, err := suite.client.GetScanReport("id3", MimeTypeNativeReport, "")
require.Error(suite.T(), err)
require.Condition(suite.T(), func() (success bool) {
_, success = err.(*ReportNotReadyError)

View File

@ -21,6 +21,11 @@ import (
"github.com/goharbor/harbor/src/lib/errors"
)
const (
supportVulnerability = "support_vulnerability"
supportSBOM = "support_sbom"
)
var supportedMimeTypes = []string{
MimeTypeNativeReport,
MimeTypeGenericVulnerabilityReport,
@ -153,6 +158,20 @@ func (md *ScannerAdapterMetadata) GetCapability(mimeType string) *ScannerCapabil
return nil
}
// ConvertCapability converts the capability to map, used in get scanner API
func (md *ScannerAdapterMetadata) ConvertCapability() map[string]interface{} {
capabilities := make(map[string]interface{})
for _, c := range md.Capabilities {
if c.Type == ScanTypeVulnerability {
capabilities[supportVulnerability] = true
}
if c.Type == ScanTypeSbom {
capabilities[supportSBOM] = true
}
}
return capabilities
}
// Artifact represents an artifact stored in Registry.
type Artifact struct {
// ID of the namespace (project). It will not be sent to scanner adapter.

View File

@ -0,0 +1,43 @@
// 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
const (
// SBOMRepository ...
SBOMRepository = "sbom_repository"
// SBOMDigest ...
SBOMDigest = "sbom_digest"
// StartTime ...
StartTime = "start_time"
// EndTime ...
EndTime = "end_time"
// Duration ...
Duration = "duration"
// ScanStatus ...
ScanStatus = "scan_status"
)
// Summary includes the sbom summary information
type Summary map[string]interface{}
// SBOMAccArt returns the repository and digest of the SBOM
func (s Summary) SBOMAccArt() (repo, digest string) {
if repo, ok := s[SBOMRepository].(string); ok {
if digest, ok := s[SBOMDigest].(string); ok {
return repo, digest
}
}
return "", ""
}

162
src/pkg/scan/sbom/sbom.go Normal file
View File

@ -0,0 +1,162 @@
// 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 sbom
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/goharbor/harbor/src/common"
"github.com/goharbor/harbor/src/lib/config"
scanModel "github.com/goharbor/harbor/src/pkg/scan/dao/scan"
sbom "github.com/goharbor/harbor/src/pkg/scan/sbom/model"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/jobservice/job"
"github.com/goharbor/harbor/src/pkg/permission/types"
"github.com/goharbor/harbor/src/pkg/robot/model"
"github.com/goharbor/harbor/src/pkg/scan"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/pkg/scan/vuln"
)
const (
sbomMimeType = "application/vnd.goharbor.harbor.sbom.v1"
sbomMediaTypeSpdx = "application/spdx+json"
)
func init() {
scan.RegisterScanHanlder(v1.ScanTypeSbom, &scanHandler{GenAccessoryFunc: scan.GenAccessoryArt, RegistryServer: registryFQDN})
}
// ScanHandler defines the Handler to generate sbom
type scanHandler struct {
GenAccessoryFunc func(scanRep v1.ScanRequest, sbomContent []byte, labels map[string]string, mediaType string, robot *model.Robot) (string, error)
RegistryServer func(ctx context.Context) string
}
// RequestProducesMineTypes defines the mine types produced by the scan handler
func (v *scanHandler) RequestProducesMineTypes() []string {
return []string{v1.MimeTypeSBOMReport}
}
// RequestParameters defines the parameters for scan request
func (v *scanHandler) RequestParameters() map[string]interface{} {
return map[string]interface{}{"sbom_media_types": []string{sbomMediaTypeSpdx}}
}
// ReportURLParameter defines the parameters for scan report url
func (v *scanHandler) ReportURLParameter(_ *v1.ScanRequest) (string, error) {
return fmt.Sprintf("sbom_media_type=%s", url.QueryEscape(sbomMediaTypeSpdx)), nil
}
// RequiredPermissions defines the permission used by the scan robot account
func (v *scanHandler) RequiredPermissions() []*types.Policy {
return []*types.Policy{
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionPull,
},
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionScannerPull,
},
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionPush,
},
}
}
// PostScan defines task specific operations after the scan is complete
func (v *scanHandler) PostScan(ctx job.Context, sr *v1.ScanRequest, _ *scanModel.Report, rawReport string, startTime time.Time, robot *model.Robot) (string, error) {
sbomContent, err := retrieveSBOMContent(rawReport)
if err != nil {
return "", err
}
scanReq := v1.ScanRequest{
Registry: sr.Registry,
Artifact: sr.Artifact,
}
// the registry server url is core by default, need to replace it with real registry server url
scanReq.Registry.URL = v.RegistryServer(ctx.SystemContext())
if len(scanReq.Registry.URL) == 0 {
return "", fmt.Errorf("empty registry server")
}
myLogger := ctx.GetLogger()
myLogger.Debugf("Pushing accessory artifact to %s/%s", scanReq.Registry.URL, scanReq.Artifact.Repository)
dgst, err := v.GenAccessoryFunc(scanReq, sbomContent, v.annotations(), sbomMimeType, robot)
if err != nil {
myLogger.Errorf("error when create accessory from image %v", err)
return "", err
}
return v.generateReport(startTime, sr.Artifact.Repository, dgst, "Success")
}
// annotations defines the annotations for the accessory artifact
func (v *scanHandler) annotations() map[string]string {
return map[string]string{
"created-by": "Harbor",
"org.opencontainers.artifact.created": time.Now().Format(time.RFC3339),
"org.opencontainers.artifact.description": "SPDX JSON SBOM",
}
}
func (v *scanHandler) generateReport(startTime time.Time, repository, digest, status string) (string, error) {
summary := sbom.Summary{}
endTime := time.Now()
summary[sbom.StartTime] = startTime
summary[sbom.EndTime] = endTime
summary[sbom.Duration] = int64(endTime.Sub(startTime).Seconds())
summary[sbom.SBOMRepository] = repository
summary[sbom.SBOMDigest] = digest
summary[sbom.ScanStatus] = status
rep, err := json.Marshal(summary)
if err != nil {
return "", err
}
return string(rep), nil
}
// extract server name from config, and remove the protocol prefix
func registryFQDN(ctx context.Context) string {
cfgMgr, ok := config.FromContext(ctx)
if ok {
extURL := cfgMgr.Get(context.Background(), common.ExtEndpoint).GetString()
server := strings.TrimPrefix(extURL, "https://")
server = strings.TrimPrefix(server, "http://")
return server
}
return ""
}
// retrieveSBOMContent retrieves the "sbom" field from the raw report
func retrieveSBOMContent(rawReport string) ([]byte, error) {
rpt := vuln.Report{}
err := json.Unmarshal([]byte(rawReport), &rpt)
if err != nil {
return nil, err
}
sbomContent, err := json.Marshal(rpt.SBOM)
if err != nil {
return nil, err
}
return sbomContent, nil
}

View File

@ -0,0 +1,139 @@
package sbom
import (
"context"
"reflect"
"testing"
"time"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/pkg/permission/types"
"github.com/goharbor/harbor/src/pkg/robot/model"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/testing/jobservice"
"github.com/stretchr/testify/suite"
)
func Test_scanHandler_ReportURLParameter(t *testing.T) {
type args struct {
in0 *v1.ScanRequest
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{"normal test", args{&v1.ScanRequest{}}, "sbom_media_type=application%2Fspdx%2Bjson", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &scanHandler{}
got, err := v.ReportURLParameter(tt.args.in0)
if (err != nil) != tt.wantErr {
t.Errorf("ReportURLParameter() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ReportURLParameter() got = %v, want %v", got, tt.want)
}
})
}
}
func Test_scanHandler_RequiredPermissions(t *testing.T) {
tests := []struct {
name string
want []*types.Policy
}{
{"normal test", []*types.Policy{
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionPull,
},
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionScannerPull,
},
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionPush,
},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &scanHandler{}
if got := v.RequiredPermissions(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("RequiredPermissions() = %v, want %v", got, tt.want)
}
})
}
}
func Test_scanHandler_RequestProducesMineTypes(t *testing.T) {
tests := []struct {
name string
want []string
}{
{"normal test", []string{v1.MimeTypeSBOMReport}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &scanHandler{}
if got := v.RequestProducesMineTypes(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("RequestProducesMineTypes() = %v, want %v", got, tt.want)
}
})
}
}
func mockGetRegistry(ctx context.Context) string {
return "myharbor.example.com"
}
func mockGenAccessory(scanRep v1.ScanRequest, sbomContent []byte, labels map[string]string, mediaType string, robot *model.Robot) (string, error) {
return "sha256:1234567890", nil
}
type ExampleTestSuite struct {
handler *scanHandler
suite.Suite
}
func (suite *ExampleTestSuite) SetupSuite() {
suite.handler = &scanHandler{
GenAccessoryFunc: mockGenAccessory,
RegistryServer: mockGetRegistry,
}
}
func (suite *ExampleTestSuite) TearDownSuite() {
}
func (suite *ExampleTestSuite) TestPostScan() {
req := &v1.ScanRequest{
Registry: &v1.Registry{
URL: "myregistry.example.com",
},
Artifact: &v1.Artifact{
Repository: "library/nosql",
},
}
robot := &model.Robot{
Name: "robot",
Secret: "mysecret",
}
startTime := time.Now()
rawReport := `{"sbom": { "key": "value" }}`
ctx := &jobservice.MockJobContext{}
ctx.On("GetLogger").Return(&jobservice.MockJobLogger{})
accessory, err := suite.handler.PostScan(ctx, req, nil, rawReport, startTime, robot)
suite.Require().NoError(err)
suite.Require().NotEmpty(accessory)
}
func TestExampleTestSuite(t *testing.T) {
suite.Run(t, &ExampleTestSuite{})
}

View File

@ -35,6 +35,16 @@ func init() {
type ScanHandler struct {
}
// RequestProducesMineTypes returns the produces mime types
func (v *ScanHandler) RequestProducesMineTypes() []string {
return []string{v1.MimeTypeGenericVulnerabilityReport}
}
// RequestParameters defines the parameters for scan request
func (v *ScanHandler) RequestParameters() map[string]interface{} {
return nil
}
// RequiredPermissions defines the permission used by the scan robot account
func (v *ScanHandler) RequiredPermissions() []*types.Policy {
return []*types.Policy{
@ -49,6 +59,11 @@ func (v *ScanHandler) RequiredPermissions() []*types.Policy {
}
}
// ReportURLParameter vulnerability doesn't require any scan report parameters
func (v *ScanHandler) ReportURLParameter(_ *v1.ScanRequest) (string, error) {
return "", nil
}
// PostScan ...
func (v *ScanHandler) PostScan(ctx job.Context, _ *v1.ScanRequest, origRp *scan.Report, rawReport string, _ time.Time, _ *model.Robot) (string, error) {
// use a new ormer here to use the short db connection

View File

@ -1,6 +1,7 @@
package vulnerability
import (
"fmt"
"testing"
"time"
@ -50,3 +51,66 @@ func TestPostScan(t *testing.T) {
assert.Equal(t, "", refreshedReport, "PostScan should return the refreshed report")
assert.Nil(t, err, "PostScan should not return an error")
}
func TestScanHandler_RequiredPermissions(t *testing.T) {
tests := []struct {
name string
want []*types.Policy
}{
{"normal", []*types.Policy{
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionPull,
},
{
Resource: rbac.ResourceRepository,
Action: rbac.ActionScannerPull,
},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &ScanHandler{}
assert.Equalf(t, tt.want, v.RequiredPermissions(), "RequiredPermissions()")
})
}
}
func TestScanHandler_ReportURLParameter(t *testing.T) {
type args struct {
in0 *v1.ScanRequest
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{"normal", args{&v1.ScanRequest{}}, "", assert.NoError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &ScanHandler{}
got, err := v.ReportURLParameter(tt.args.in0)
if !tt.wantErr(t, err, fmt.Sprintf("ReportURLParameter(%v)", tt.args.in0)) {
return
}
assert.Equalf(t, tt.want, got, "ReportURLParameter(%v)", tt.args.in0)
})
}
}
func TestScanHandler_RequestProducesMineTypes(t *testing.T) {
tests := []struct {
name string
want []string
}{
{"normal", []string{v1.MimeTypeGenericVulnerabilityReport}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &ScanHandler{}
assert.Equalf(t, tt.want, v.RequestProducesMineTypes(), "RequestProducesMineTypes()")
})
}
}

View File

@ -52,6 +52,7 @@ func (s *ScannerRegistration) ToSwagger(_ context.Context) *models.ScannerRegist
Vendor: s.Vendor,
Version: s.Version,
Health: s.Health,
Capabilities: s.Capabilities,
}
}

View File

@ -594,7 +594,13 @@ func (a *projectAPI) GetScannerOfProject(ctx context.Context, params operation.G
if err != nil {
return a.SendError(ctx, err)
}
if scanner != nil {
metadata, err := a.scannerCtl.GetMetadata(ctx, scanner.UUID)
if err != nil {
return a.SendError(ctx, err)
}
scanner.Capabilities = metadata.ConvertCapability()
}
return operation.NewGetScannerOfProjectOK().WithPayload(model.NewScannerRegistration(scanner).ToSwagger(ctx))
}

View File

@ -22,6 +22,7 @@ import (
"github.com/goharbor/harbor/src/pkg/project/models"
"github.com/goharbor/harbor/src/pkg/scan/dao/scanner"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/server/v2.0/restapi"
projecttesting "github.com/goharbor/harbor/src/testing/controller/project"
scannertesting "github.com/goharbor/harbor/src/testing/controller/scanner"
@ -36,6 +37,7 @@ type ProjectTestSuite struct {
scannerCtl *scannertesting.Controller
project *models.Project
reg *scanner.Registration
metadata *v1.ScannerAdapterMetadata
}
func (suite *ProjectTestSuite) SetupSuite() {
@ -59,7 +61,12 @@ func (suite *ProjectTestSuite) SetupSuite() {
scannerCtl: suite.scannerCtl,
},
}
suite.metadata = &v1.ScannerAdapterMetadata{
Capabilities: []*v1.ScannerCapability{
{Type: "vulnerability", ProducesMimeTypes: []string{v1.MimeTypeScanResponse}},
{Type: "sbom", ProducesMimeTypes: []string{v1.MimeTypeSBOMReport}},
},
}
suite.Suite.SetupSuite()
}
@ -81,7 +88,7 @@ func (suite *ProjectTestSuite) TestGetScannerOfProject() {
// scanner not found
mock.OnAnything(suite.projectCtl, "Get").Return(suite.project, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetRegistrationByProject").Return(nil, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetMetadata").Return(suite.metadata, nil).Once()
res, err := suite.Get("/projects/1/scanner")
suite.NoError(err)
suite.Equal(200, res.StatusCode)
@ -90,7 +97,7 @@ func (suite *ProjectTestSuite) TestGetScannerOfProject() {
{
mock.OnAnything(suite.projectCtl, "Get").Return(suite.project, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetRegistrationByProject").Return(suite.reg, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetMetadata").Return(suite.metadata, nil).Once()
var scanner scanner.Registration
res, err := suite.GetJSON("/projects/1/scanner", &scanner)
suite.NoError(err)
@ -101,6 +108,7 @@ func (suite *ProjectTestSuite) TestGetScannerOfProject() {
{
mock.OnAnything(projectCtlMock, "GetByName").Return(suite.project, nil).Once()
mock.OnAnything(suite.projectCtl, "Get").Return(suite.project, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetMetadata").Return(suite.metadata, nil).Once()
mock.OnAnything(suite.scannerCtl, "GetRegistrationByProject").Return(suite.reg, nil).Once()
var scanner scanner.Registration

View File

@ -25,6 +25,7 @@ import (
"github.com/goharbor/harbor/src/controller/scan"
"github.com/goharbor/harbor/src/lib/errors"
"github.com/goharbor/harbor/src/pkg/distribution"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
operation "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/scan"
)
@ -50,7 +51,15 @@ func (s *scanAPI) Prepare(ctx context.Context, _ string, params interface{}) mid
}
func (s *scanAPI) StopScanArtifact(ctx context.Context, params operation.StopScanArtifactParams) middleware.Responder {
if err := s.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionStop, rbac.ResourceScan); err != nil {
scanType := v1.ScanTypeVulnerability
if params.ScanType != nil && validScanType(params.ScanType.ScanType) {
scanType = params.ScanType.ScanType
}
res := rbac.ResourceScan
if scanType == v1.ScanTypeSbom {
res = rbac.ResourceSBOM
}
if err := s.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionStop, res); err != nil {
return s.SendError(ctx, err)
}
@ -68,22 +77,26 @@ func (s *scanAPI) StopScanArtifact(ctx context.Context, params operation.StopSca
}
func (s *scanAPI) ScanArtifact(ctx context.Context, params operation.ScanArtifactParams) middleware.Responder {
if err := s.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionCreate, rbac.ResourceScan); err != nil {
return s.SendError(ctx, err)
}
repository := fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName)
artifact, err := s.artCtl.GetByReference(ctx, repository, params.Reference, nil)
if err != nil {
return s.SendError(ctx, err)
}
scanType := v1.ScanTypeVulnerability
options := []scan.Option{}
if !distribution.IsDigest(params.Reference) {
options = append(options, scan.WithTag(params.Reference))
}
if params.ScanRequestType != nil && validScanType(params.ScanRequestType.ScanType) {
options = append(options, scan.WithScanType(params.ScanRequestType.ScanType))
scanType = params.ScanRequestType.ScanType
options = append(options, scan.WithScanType(scanType))
}
res := rbac.ResourceScan
if scanType == v1.ScanTypeSbom {
res = rbac.ResourceSBOM
}
if err := s.RequireProjectAccess(ctx, params.ProjectName, rbac.ActionCreate, res); err != nil {
return s.SendError(ctx, err)
}
repository := fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName)
artifact, err := s.artCtl.GetByReference(ctx, repository, params.Reference, nil)
if err != nil {
return s.SendError(ctx, err)
}
if err := s.scanCtl.Scan(ctx, artifact, options...); err != nil {

View File

@ -42,9 +42,9 @@ func (_m *Client) GetMetadata() (*v1.ScannerAdapterMetadata, error) {
return r0, r1
}
// GetScanReport provides a mock function with given fields: scanRequestID, reportMIMEType
func (_m *Client) GetScanReport(scanRequestID string, reportMIMEType string) (string, error) {
ret := _m.Called(scanRequestID, reportMIMEType)
// GetScanReport provides a mock function with given fields: scanRequestID, reportMIMEType, urlParameter
func (_m *Client) GetScanReport(scanRequestID string, reportMIMEType string, urlParameter string) (string, error) {
ret := _m.Called(scanRequestID, reportMIMEType, urlParameter)
if len(ret) == 0 {
panic("no return value specified for GetScanReport")
@ -52,17 +52,17 @@ func (_m *Client) GetScanReport(scanRequestID string, reportMIMEType string) (st
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok {
return rf(scanRequestID, reportMIMEType)
if rf, ok := ret.Get(0).(func(string, string, string) (string, error)); ok {
return rf(scanRequestID, reportMIMEType, urlParameter)
}
if rf, ok := ret.Get(0).(func(string, string) string); ok {
r0 = rf(scanRequestID, reportMIMEType)
if rf, ok := ret.Get(0).(func(string, string, string) string); ok {
r0 = rf(scanRequestID, reportMIMEType, urlParameter)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(scanRequestID, reportMIMEType)
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
r1 = rf(scanRequestID, reportMIMEType, urlParameter)
} else {
r1 = ret.Error(1)
}