diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 46d97702840..7f6f36f484b 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -6052,7 +6052,40 @@ paths: $ref: '#/responses/404' '500': $ref: '#/responses/500' - + /security/summary: + get: + summary: Get vulnerability system summary + description: Retrieve the vulnerability summary of the system + tags: + - securityhub + operationId: getSecuritySummary + parameters: + - $ref: '#/parameters/requestId' + - name: with_dangerous_cve + in: query + description: Specify whether the dangerous CVE is include in the security summary + type: boolean + required: false + default: false + - name: with_dangerous_artifact + in: query + description: Specify whether the dangerous artifacts is include in the security summary + type: boolean + required: false + default: false + responses: + '200': + description: Success + schema: + $ref: '#/definitions/SecuritySummary' + '401': + $ref: '#/responses/401' + '403': + $ref: '#/responses/403' + '404': + $ref: '#/responses/404' + '500': + $ref: '#/responses/500' parameters: query: name: q @@ -9614,3 +9647,116 @@ definitions: type: boolean description: if the scheduler is paused x-omitempty: false + SecuritySummary: + type: object + description: the security summary + properties: + critical_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of critical vulnerabilities + high_cnt: + type: integer + format: int64 + description: the count of high vulnerabilities + medium_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of medium vulnerabilities + low_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of low vulnerabilities + none_cnt: + type: integer + format: int64 + description: the count of none vulnerabilities + unknown_cnt: + type: integer + format: int64 + description: the count of unknown vulnerabilities + total_vuls: + type: integer + format: int64 + x-omitempty: false + description: the count of total vulnerabilities + scanned_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of scanned artifacts + total_artifact: + type: integer + format: int64 + x-omitempty: false + description: the total count of artifacts + fixable_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of fixable vulnerabilities + dangerous_cves: + type: array + x-omitempty: true + description: the list of dangerous CVEs + items: + $ref: '#/definitions/DangerousCVE' + dangerous_artifacts: + type: array + x-omitempty: true + description: the list of dangerous artifacts + items: + $ref: '#/definitions/DangerousArtifact' + DangerousCVE: + type: object + description: the dangerous CVE information + properties: + cve_id: + type: string + description: the cve id + severity: + type: string + description: the severity of the CVE + cvss_score_v3: + type: number + format: float64 + description: the cvss score v3 + desc: + type: string + description: the description of the CVE + package: + type: string + description: the package of the CVE + version: + type: string + description: the version of the package + DangerousArtifact: + type: object + description: the dangerous artifact information + properties: + project_id: + type: integer + format: int64 + description: the project id of the artifact + repository_name: + type: string + description: the repository name of the artifact + digest: + type: string + description: the digest of the artifact + critical_cnt: + type: integer + x-omitempty: false + description: the count of critical vulnerabilities + high_cnt: + type: integer + format: int64 + x-omitempty: false + description: the count of high vulnerabilities + medium_cnt: + type: integer + x-omitempty: false + description: the count of medium vulnerabilities diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go index b085eeb6cdb..282779c4905 100644 --- a/src/common/rbac/const.go +++ b/src/common/rbac/const.go @@ -75,4 +75,5 @@ const ( ResourcePurgeAuditLog = Resource("purge-audit") ResourceExportCVE = Resource("export-cve") ResourceJobServiceMonitor = Resource("jobservice-monitor") + ResourceSecurityHub = Resource("security-hub") ) diff --git a/src/common/rbac/system/policies.go b/src/common/rbac/system/policies.go index f3e6a6a81d6..8fd769380f9 100644 --- a/src/common/rbac/system/policies.go +++ b/src/common/rbac/system/policies.go @@ -84,5 +84,7 @@ var ( {Resource: rbac.ResourceJobServiceMonitor, Action: rbac.ActionRead}, {Resource: rbac.ResourceJobServiceMonitor, Action: rbac.ActionList}, {Resource: rbac.ResourceJobServiceMonitor, Action: rbac.ActionStop}, + + {Resource: rbac.ResourceSecurityHub, Action: rbac.ActionRead}, } ) diff --git a/src/controller/securityhub/controller.go b/src/controller/securityhub/controller.go new file mode 100644 index 00000000000..f83af4956c0 --- /dev/null +++ b/src/controller/securityhub/controller.go @@ -0,0 +1,138 @@ +// 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 securityhub + +import ( + "context" + + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg" + "github.com/goharbor/harbor/src/pkg/artifact" + "github.com/goharbor/harbor/src/pkg/scan/scanner" + "github.com/goharbor/harbor/src/pkg/securityhub" + secHubModel "github.com/goharbor/harbor/src/pkg/securityhub/model" +) + +// Ctl is the global controller for security hub +var Ctl = NewController() + +// Options define the option to query summary info +type Options struct { + WithCVE bool + WithArtifact bool +} + +// Option define the func to build options +type Option func(*Options) + +func newOptions(options ...Option) *Options { + opts := &Options{} + for _, f := range options { + f(opts) + } + return opts +} + +// WithCVE enable CVE info in summary +func WithCVE(enable bool) Option { + return func(o *Options) { + o.WithCVE = enable + } +} + +// WithArtifact enable artifact info in summary +func WithArtifact(enable bool) Option { + return func(o *Options) { + o.WithArtifact = enable + } +} + +// Controller controller of security hub +type Controller interface { + // SecuritySummary returns the security summary of the specified project. + SecuritySummary(ctx context.Context, projectID int64, options ...Option) (*secHubModel.Summary, error) +} + +type controller struct { + artifactMgr artifact.Manager + scannerMgr scanner.Manager + secHubMgr securityhub.Manager +} + +// NewController ... +func NewController() Controller { + return &controller{ + artifactMgr: pkg.ArtifactMgr, + scannerMgr: scanner.New(), + secHubMgr: securityhub.Mgr, + } +} + +func (c *controller) SecuritySummary(ctx context.Context, projectID int64, options ...Option) (*secHubModel.Summary, error) { + opts := newOptions(options...) + scannerUUID, err := c.defaultScannerUUID(ctx) + if err != nil { + return nil, err + } + sum, err := c.secHubMgr.Summary(ctx, scannerUUID, projectID, nil) + if err != nil { + return nil, err + } + sum.TotalArtifactCnt, err = c.totalArtifactCount(ctx, projectID) + if err != nil { + return nil, err + } + sum.ScannedCnt, err = c.secHubMgr.ScannedArtifactsCount(ctx, scannerUUID, projectID, nil) + if err != nil { + return nil, err + } + if opts.WithCVE { + sum.DangerousCVEs, err = c.secHubMgr.DangerousCVEs(ctx, scannerUUID, projectID, nil) + if err != nil { + return nil, err + } + } + if opts.WithArtifact { + sum.DangerousArtifacts, err = c.secHubMgr.DangerousArtifacts(ctx, scannerUUID, projectID, nil) + if err != nil { + return nil, err + } + } + return sum, nil +} + +func (c *controller) scannedArtifactCount(ctx context.Context, projectID int64) (int64, error) { + scannerUUID, err := c.defaultScannerUUID(ctx) + if err != nil { + return 0, err + } + return c.secHubMgr.ScannedArtifactsCount(ctx, scannerUUID, projectID, nil) +} + +func (c *controller) totalArtifactCount(ctx context.Context, projectID int64) (int64, error) { + if projectID == 0 { + return c.artifactMgr.Count(ctx, nil) + } + return c.artifactMgr.Count(ctx, q.New(q.KeyWords{"project_id": projectID})) +} + +// defaultScannerUUID returns the default scanner uuid. +func (c *controller) defaultScannerUUID(ctx context.Context) (string, error) { + reg, err := c.scannerMgr.GetDefault(ctx) + if err != nil { + return "", err + } + return reg.UUID, nil +} diff --git a/src/controller/securityhub/controller_test.go b/src/controller/securityhub/controller_test.go new file mode 100644 index 00000000000..987a7619da8 --- /dev/null +++ b/src/controller/securityhub/controller_test.go @@ -0,0 +1,157 @@ +// 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 securityhub + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/goharbor/harbor/src/pkg/scan/dao/scan" + "github.com/goharbor/harbor/src/pkg/scan/dao/scanner" + "github.com/goharbor/harbor/src/pkg/securityhub/model" + htesting "github.com/goharbor/harbor/src/testing" + "github.com/goharbor/harbor/src/testing/mock" + artifactMock "github.com/goharbor/harbor/src/testing/pkg/artifact" + scannerMock "github.com/goharbor/harbor/src/testing/pkg/scan/scanner" + securityMock "github.com/goharbor/harbor/src/testing/pkg/securityhub" +) + +var sum = &model.Summary{ + CriticalCnt: 50, + HighCnt: 40, + MediumCnt: 30, + LowCnt: 20, + NoneCnt: 10, + FixableCnt: 90, +} + +type ControllerTestSuite struct { + htesting.Suite + c *controller + artifactMgr *artifactMock.Manager + scannerMgr *scannerMock.Manager + secHubMgr *securityMock.Manager +} + +// TestController is the entry of controller test suite +func TestController(t *testing.T) { + suite.Run(t, new(ControllerTestSuite)) +} + +// SetupTest prepares env for the controller test suite +func (suite *ControllerTestSuite) SetupTest() { + suite.artifactMgr = &artifactMock.Manager{} + suite.secHubMgr = &securityMock.Manager{} + suite.scannerMgr = &scannerMock.Manager{} + suite.c = &controller{ + artifactMgr: suite.artifactMgr, + secHubMgr: suite.secHubMgr, + scannerMgr: suite.scannerMgr, + } +} + +func (suite *ControllerTestSuite) TearDownTest() { +} + +// TestSecuritySummary tests the security summary +func (suite *ControllerTestSuite) TestSecuritySummary() { + ctx := suite.Context() + + mock.OnAnything(suite.artifactMgr, "Count").Return(int64(1234), nil) + mock.OnAnything(suite.secHubMgr, "ScannedArtifactsCount").Return(int64(1000), nil) + mock.OnAnything(suite.secHubMgr, "Summary").Return(sum, nil).Twice() + mock.OnAnything(suite.scannerMgr, "GetDefault").Return(&scanner.Registration{UUID: "ruuid"}, nil) + summary, err := suite.c.SecuritySummary(ctx, 0, WithArtifact(false), WithCVE(false)) + suite.NoError(err) + suite.NotNil(summary) + suite.Equal(int64(1234), summary.TotalArtifactCnt) + suite.Equal(int64(1000), summary.ScannedCnt) + suite.Equal(int64(50), summary.CriticalCnt) + suite.Equal(int64(40), summary.HighCnt) + suite.Equal(int64(30), summary.MediumCnt) + suite.Equal(int64(20), summary.LowCnt) + suite.Equal(int64(10), summary.NoneCnt) + suite.Equal(int64(90), summary.FixableCnt) + sum.DangerousCVEs = []*scan.VulnerabilityRecord{ + {CVEID: "CVE-2020-1234", Severity: "CRITICAL"}, + {CVEID: "CVE-2020-1235", Severity: "HIGH"}, + {CVEID: "CVE-2020-1236", Severity: "MEDIUM"}, + {CVEID: "CVE-2020-1237", Severity: "LOW"}, + {CVEID: "CVE-2020-1238", Severity: "NONE"}, + } + sum.DangerousArtifacts = []*model.DangerousArtifact{ + {Project: 1, Repository: "library/busybox"}, + {Project: 1, Repository: "library/nginx"}, + {Project: 1, Repository: "library/hello-world"}, + {Project: 1, Repository: "library/harbor-jobservice"}, + {Project: 1, Repository: "library/harbor-core"}, + } + mock.OnAnything(suite.secHubMgr, "Summary").Return(sum, nil).Once() + mock.OnAnything(suite.secHubMgr, "DangerousCVEs").Return(sum.DangerousCVEs, nil).Once() + mock.OnAnything(suite.secHubMgr, "DangerousArtifacts").Return(sum.DangerousArtifacts, nil).Once() + sum2, err := suite.c.SecuritySummary(ctx, 0, WithCVE(false), WithArtifact(false)) + suite.NoError(err) + suite.NotNil(sum2) + suite.NotNil(sum2.DangerousCVEs) + suite.NotNil(sum2.DangerousArtifacts) + + sum3, err := suite.c.SecuritySummary(ctx, 0, WithCVE(true), WithArtifact(true)) + suite.NoError(err) + suite.NotNil(sum3) + suite.True(len(sum3.DangerousCVEs) > 0) + suite.True(len(sum3.DangerousArtifacts) > 0) +} + +// TestSecuritySummaryError tests the security summary with error +func (suite *ControllerTestSuite) TestSecuritySummaryError() { + ctx := suite.Context() + mock.OnAnything(suite.scannerMgr, "GetDefault").Return(&scanner.Registration{UUID: "ruuid"}, nil) + mock.OnAnything(suite.secHubMgr, "ScannedArtifactsCount").Return(int64(1000), nil) + mock.OnAnything(suite.secHubMgr, "Summary").Return(nil, errors.New("invalid project")).Once() + summary, err := suite.c.SecuritySummary(ctx, 0, WithCVE(false), WithArtifact(false)) + suite.Error(err) + suite.Nil(summary) + mock.OnAnything(suite.artifactMgr, "Count").Return(int64(0), errors.New("failed to connect db")).Once() + mock.OnAnything(suite.secHubMgr, "Summary").Return(sum, nil).Once() + summary, err = suite.c.SecuritySummary(ctx, 0, WithCVE(false), WithArtifact(false)) + suite.Error(err) + suite.Nil(summary) + +} + +// TestGetDefaultScanner tests the get default scanner +func (suite *ControllerTestSuite) TestGetDefaultScanner() { + ctx := suite.Context() + mock.OnAnything(suite.scannerMgr, "GetDefault").Return(&scanner.Registration{UUID: ""}, nil).Once() + scanner, err := suite.c.defaultScannerUUID(ctx) + suite.NoError(err) + suite.Equal("", scanner) + + mock.OnAnything(suite.scannerMgr, "GetDefault").Return(nil, errors.New("failed to get scanner")).Once() + scanner, err = suite.c.defaultScannerUUID(ctx) + suite.Error(err) + suite.Equal("", scanner) +} + +func (suite *ControllerTestSuite) TestScannedArtifact() { + ctx := suite.Context() + mock.OnAnything(suite.scannerMgr, "GetDefault").Return(&scanner.Registration{UUID: "ruuid"}, nil) + mock.OnAnything(suite.secHubMgr, "ScannedArtifactsCount").Return(int64(1000), nil) + scanned, err := suite.c.scannedArtifactCount(ctx, 0) + suite.NoError(err) + suite.Equal(int64(1000), scanned) +} diff --git a/src/pkg/scan/postprocessors/report_converters.go b/src/pkg/scan/postprocessors/report_converters.go index c3bb51363b2..3acea71dd03 100644 --- a/src/pkg/scan/postprocessors/report_converters.go +++ b/src/pkg/scan/postprocessors/report_converters.go @@ -71,6 +71,10 @@ func (c *nativeToRelationalSchemaConverter) ToRelationalSchema(ctx context.Conte return "", "", errors.Wrap(err, "Error when converting vulnerability report") } + if err := c.updateReport(ctx, rawReport.Vulnerabilities, reportUUID); err != nil { + return "", "", errors.Wrap(err, "Error when updating report") + } + rawReport.Vulnerabilities = nil data, err := json.Marshal(rawReport) if err != nil { diff --git a/src/pkg/securityhub/dao/security.go b/src/pkg/securityhub/dao/security.go new file mode 100644 index 00000000000..8adc67f0ac2 --- /dev/null +++ b/src/pkg/securityhub/dao/security.go @@ -0,0 +1,133 @@ +// 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 dao + +import ( + "context" + + "github.com/goharbor/harbor/src/lib/orm" + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/scan/dao/scan" + "github.com/goharbor/harbor/src/pkg/securityhub/model" +) + +const ( + summarySQL = `select sum(s.critical_cnt) critical_cnt, + sum(s.high_cnt) high_cnt, + sum(s.medium_cnt) medium_cnt, + sum(s.low_cnt) low_cnt, + sum(s.none_cnt) none_cnt, + sum(s.unknown_cnt) unknown_cnt, + sum(s.fixable_cnt) fixable_cnt +from artifact a + left join scan_report s on a.digest = s.digest + where s.registration_uuid = ?` + + dangerousArtifactSQL = `select a.project_id project, a.repository_name repository, a.digest, s.critical_cnt, s.high_cnt, s.medium_cnt, s.low_cnt +from artifact a, + scan_report s +where a.digest = s.digest + and s.registration_uuid = ? +order by s.critical_cnt desc, s.high_cnt desc, s.medium_cnt desc, s.low_cnt desc +limit 5` + + scannedArtifactCountSQL = `select count(1) + from artifact a + left join scan_report s on a.digest = s.digest + where s.registration_uuid= ? and s.uuid is not null` + + dangerousCVESQL = `select vr.* +from vulnerability_record vr +where vr.cvss_score_v3 is not null +and vr.registration_uuid = ? +order by vr.cvss_score_v3 desc +limit 5` +) + +// SecurityHubDao defines the interface to access security hub data. +type SecurityHubDao interface { + // Summary returns the summary of the scan cve reports. + Summary(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (*model.Summary, error) + // DangerousCVEs get the top 5 most dangerous CVEs, return top 5 result + DangerousCVEs(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*scan.VulnerabilityRecord, error) + // DangerousArtifacts returns top 5 dangerous artifact for the given scanner. return top 5 result + DangerousArtifacts(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*model.DangerousArtifact, error) + // ScannedArtifactsCount return the count of scanned artifacts. + ScannedArtifactsCount(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (int64, error) +} + +// New creates a new SecurityHubDao instance. +func New() SecurityHubDao { + return &dao{} +} + +type dao struct { +} + +func (d *dao) Summary(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (*model.Summary, error) { + if len(scannerUUID) == 0 || projectID != 0 { + return nil, nil + } + o, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + var sum model.Summary + err = o.Raw(summarySQL, scannerUUID).QueryRow(&sum.CriticalCnt, + &sum.HighCnt, + &sum.MediumCnt, + &sum.LowCnt, + &sum.NoneCnt, + &sum.UnknownCnt, + &sum.FixableCnt) + return &sum, err +} +func (d *dao) DangerousArtifacts(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*model.DangerousArtifact, error) { + if len(scannerUUID) == 0 || projectID != 0 { + return nil, nil + } + o, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + var artifacts []*model.DangerousArtifact + _, err = o.Raw(dangerousArtifactSQL, scannerUUID).QueryRows(&artifacts) + return artifacts, err +} + +func (d *dao) ScannedArtifactsCount(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (int64, error) { + if len(scannerUUID) == 0 || projectID != 0 { + return 0, nil + } + var cnt int64 + o, err := orm.FromContext(ctx) + if err != nil { + return cnt, err + } + err = o.Raw(scannedArtifactCountSQL, scannerUUID).QueryRow(&cnt) + return cnt, err +} +func (d *dao) DangerousCVEs(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*scan.VulnerabilityRecord, error) { + if len(scannerUUID) == 0 || projectID != 0 { + return nil, nil + } + cves := make([]*scan.VulnerabilityRecord, 0) + o, err := orm.FromContext(ctx) + if err != nil { + return nil, err + } + _, err = o.Raw(dangerousCVESQL, scannerUUID).QueryRows(&cves) + return cves, err +} diff --git a/src/pkg/securityhub/dao/security_test.go b/src/pkg/securityhub/dao/security_test.go new file mode 100644 index 00000000000..7cab1ab4a78 --- /dev/null +++ b/src/pkg/securityhub/dao/security_test.go @@ -0,0 +1,104 @@ +// 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 dao + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + testDao "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/lib/orm" + htesting "github.com/goharbor/harbor/src/testing" +) + +func TestDao(t *testing.T) { + suite.Run(t, &SecurityDaoTestSuite{}) +} + +type SecurityDaoTestSuite struct { + htesting.Suite + dao SecurityHubDao +} + +// SetupSuite prepares env for test suite. +func (suite *SecurityDaoTestSuite) SetupSuite() { + suite.Suite.SetupSuite() + suite.dao = New() +} + +// SetupTest prepares env for test case. +func (suite *SecurityDaoTestSuite) SetupTest() { + testDao.ExecuteBatchSQL([]string{ + `insert into scan_report(uuid, digest, registration_uuid, mime_type, critical_cnt, high_cnt, medium_cnt, low_cnt, unknown_cnt, fixable_cnt) values('uuid', 'digest1001', 'ruuid', 'application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0', 50, 50, 50, 0, 0, 20)`, + `insert into artifact (project_id, repository_name, digest, type, pull_time, push_time, repository_id, media_type, manifest_media_type, size, extra_attrs, annotations, icon) +values (1, 'library/hello-world', 'digest1001', 'IMAGE', '2023-06-02 09:16:47.838778', '2023-06-02 01:45:55.050785', 1742, 'application/vnd.docker.container.image.v1+json', 'application/vnd.docker.distribution.manifest.v2+json', 4452, '{"architecture":"amd64","author":"","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/hello"]},"created":"2023-05-04T17:37:03.872958712Z","os":"linux"}', null, '');`, + `insert into scanner_registration (name, url, uuid, auth) values('trivy', 'https://www.vmware.com', 'ruuid', 'empty')`, + `insert into vulnerability_record (id, cve_id, registration_uuid, cvss_score_v3) values (1, '2023-4567-12345', 'ruuid', 9.8)`, + `insert into report_vulnerability_record (report_uuid, vuln_record_id) VALUES ('uuid', 1)`, + }) + + testDao.ExecuteBatchSQL([]string{ + `INSERT INTO scanner_registration (name, url, uuid, auth) values('trivy2', 'https://www.trivy.com', 'uuid2', 'empty')`, + `INSERT INTO vulnerability_record(cve_id, registration_uuid, cvss_score_v3, package) VALUES ('CVE-2021-44228', 'uuid2', 10, 'org.apache.logging.log4j:log4j-core'); + INSERT INTO vulnerability_record(cve_id, registration_uuid, cvss_score_v3, package) VALUES ('CVE-2021-21345', 'uuid2', 9.9, 'com.thoughtworks.xstream:xstream'); + INSERT INTO vulnerability_record(cve_id, registration_uuid, cvss_score_v3, package) VALUES ('CVE-2016-1585', 'uuid2', 9.8, 'libapparmor1'); + INSERT INTO vulnerability_record(cve_id, registration_uuid, cvss_score_v3, package) VALUES ('CVE-2023-0950', 'uuid2', 9.8, 'ure'); + INSERT INTO vulnerability_record(cve_id, registration_uuid, cvss_score_v3, package) VALUES ('CVE-2022-47629', 'uuid2', 9.8, 'libksba8'); + `, + }) +} + +// TearDownTest clears enf for test case. +func (suite *SecurityDaoTestSuite) TearDownTest() { + testDao.ExecuteBatchSQL([]string{ + `delete from scan_report where uuid = 'uuid'`, + `delete from artifact where digest = 'digest1001'`, + `delete from scanner_registration where uuid='ruuid'`, + `delete from vulnerability_record where cve_id='2023-4567-12345'`, + `delete from report_vulnerability_record where report_uuid='ruuid'`, + `delete from vulnerability_record where registration_uuid ='uuid2'`, + }) +} + +func (suite *SecurityDaoTestSuite) TestGetSummary() { + s, err := suite.dao.Summary(suite.Context(), "ruuid", 0, nil) + suite.Require().NoError(err) + suite.Equal(int64(50), s.CriticalCnt) + suite.Equal(int64(50), s.HighCnt) + suite.Equal(int64(50), s.MediumCnt) + suite.Equal(int64(20), s.FixableCnt) +} +func (suite *SecurityDaoTestSuite) TestGetMostDangerousArtifact() { + aList, err := suite.dao.DangerousArtifacts(orm.Context(), "ruuid", 0, nil) + suite.Require().NoError(err) + suite.Equal(1, len(aList)) + suite.Equal(int64(50), aList[0].CriticalCnt) + suite.Equal(int64(50), aList[0].HighCnt) + suite.Equal(int64(50), aList[0].MediumCnt) + suite.Equal(int64(0), aList[0].LowCnt) +} + +func (suite *SecurityDaoTestSuite) TestGetScannedArtifactCount() { + count, err := suite.dao.ScannedArtifactsCount(orm.Context(), "ruuid", 0, nil) + suite.Require().NoError(err) + suite.Equal(int64(1), count) +} + +func (suite *SecurityDaoTestSuite) TestGetDangerousCVEs() { + records, err := suite.dao.DangerousCVEs(suite.Context(), `uuid2`, 0, nil) + suite.NoError(err, "Error when fetching most dangerous artifact") + suite.Equal(5, len(records)) +} diff --git a/src/pkg/securityhub/manager.go b/src/pkg/securityhub/manager.go new file mode 100644 index 00000000000..a71ebdf3976 --- /dev/null +++ b/src/pkg/securityhub/manager.go @@ -0,0 +1,69 @@ +// 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 securityhub + +import ( + "context" + + "github.com/goharbor/harbor/src/lib/q" + "github.com/goharbor/harbor/src/pkg/scan/dao/scan" + "github.com/goharbor/harbor/src/pkg/securityhub/dao" + "github.com/goharbor/harbor/src/pkg/securityhub/model" +) + +var ( + // Mgr is the global security manager + Mgr = NewManager() +) + +// Manager is used to manage the security manager. +type Manager interface { + // Summary returns the summary of the scan cve reports. + Summary(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (*model.Summary, error) + // DangerousArtifacts returns the most dangerous artifact for the given scanner. + DangerousArtifacts(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*model.DangerousArtifact, error) + // ScannedArtifactsCount return the count of scanned artifacts. + ScannedArtifactsCount(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (int64, error) + // DangerousCVEs returns the most dangerous CVEs for the given scanner. + DangerousCVEs(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*scan.VulnerabilityRecord, error) +} + +// NewManager news security manager. +func NewManager() Manager { + return &securityManager{ + dao: dao.New(), + } +} + +// securityManager is a default implementation of security manager. +type securityManager struct { + dao dao.SecurityHubDao +} + +func (s *securityManager) Summary(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (*model.Summary, error) { + return s.dao.Summary(ctx, scannerUUID, projectID, query) +} + +func (s *securityManager) DangerousArtifacts(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*model.DangerousArtifact, error) { + return s.dao.DangerousArtifacts(ctx, scannerUUID, projectID, query) +} + +func (s *securityManager) ScannedArtifactsCount(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (int64, error) { + return s.dao.ScannedArtifactsCount(ctx, scannerUUID, projectID, query) +} + +func (s *securityManager) DangerousCVEs(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*scan.VulnerabilityRecord, error) { + return s.dao.DangerousCVEs(ctx, scannerUUID, projectID, query) +} diff --git a/src/pkg/securityhub/model/model.go b/src/pkg/securityhub/model/model.go new file mode 100644 index 00000000000..2bb85cb5b27 --- /dev/null +++ b/src/pkg/securityhub/model/model.go @@ -0,0 +1,44 @@ +// 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 "github.com/goharbor/harbor/src/pkg/scan/dao/scan" + +// Summary is the summary of scan result +type Summary struct { + CriticalCnt int64 `json:"critical_cnt"` + HighCnt int64 `json:"high_cnt"` + MediumCnt int64 `json:"medium_cnt"` + LowCnt int64 `json:"low_cnt"` + NoneCnt int64 `json:"none_cnt"` + UnknownCnt int64 `json:"unknown_cnt"` + FixableCnt int64 `json:"fixable_cnt"` + ScannedCnt int64 `json:"scanned_cnt"` + NotScanCnt int64 `json:"not_scan_cnt"` + TotalArtifactCnt int64 `json:"total_artifact_cnt"` + DangerousCVEs []*scan.VulnerabilityRecord `json:"dangerous_cves"` + DangerousArtifacts []*DangerousArtifact `json:"dangerous_artifacts"` +} + +// DangerousArtifact define the most dangerous artifact +type DangerousArtifact struct { + Project int64 `json:"project" orm:"column(project)"` + Repository string `json:"repository" orm:"column(repository)"` + Digest string `json:"digest" orm:"column(digest)"` + CriticalCnt int64 `json:"critical_cnt" orm:"column(critical_cnt)"` + HighCnt int64 `json:"high_cnt" orm:"column(high_cnt)"` + MediumCnt int64 `json:"medium_cnt" orm:"column(medium_cnt)"` + LowCnt int64 `json:"low_cnt" orm:"column(low_cnt)"` +} diff --git a/src/server/v2.0/handler/handler.go b/src/server/v2.0/handler/handler.go index 597756d3748..7784b5d9b94 100644 --- a/src/server/v2.0/handler/handler.go +++ b/src/server/v2.0/handler/handler.go @@ -69,6 +69,7 @@ func New() http.Handler { ScanDataExportAPI: newScanDataExportAPI(), JobserviceAPI: newJobServiceAPI(), ScheduleAPI: newScheduleAPI(), + SecurityhubAPI: newSecurityAPI(), }) if err != nil { log.Fatal(err) diff --git a/src/server/v2.0/handler/security.go b/src/server/v2.0/handler/security.go new file mode 100644 index 00000000000..5865874518c --- /dev/null +++ b/src/server/v2.0/handler/security.go @@ -0,0 +1,98 @@ +// 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 handler + +import ( + "context" + + "github.com/go-openapi/runtime/middleware" + + "github.com/goharbor/harbor/src/common/rbac" + "github.com/goharbor/harbor/src/server/v2.0/models" + securityModel "github.com/goharbor/harbor/src/server/v2.0/restapi/operations/securityhub" + + "github.com/goharbor/harbor/src/controller/securityhub" + "github.com/goharbor/harbor/src/pkg/scan/dao/scan" + secHubModel "github.com/goharbor/harbor/src/pkg/securityhub/model" +) + +func newSecurityAPI() *securityAPI { + return &securityAPI{ + controller: securityhub.Ctl, + } +} + +type securityAPI struct { + BaseAPI + controller securityhub.Controller +} + +func (s *securityAPI) GetSecuritySummary(ctx context.Context, + params securityModel.GetSecuritySummaryParams) middleware.Responder { + if err := s.RequireSystemAccess(ctx, rbac.ActionRead, rbac.ResourceSecurityHub); err != nil { + return s.SendError(ctx, err) + } + summary, err := s.controller.SecuritySummary(ctx, 0, securityhub.WithCVE(*params.WithDangerousCVE), securityhub.WithArtifact(*params.WithDangerousArtifact)) + if err != nil { + return s.SendError(ctx, err) + } + sum := toSecuritySummaryModel(summary) + return securityModel.NewGetSecuritySummaryOK().WithPayload(sum) +} + +func toSecuritySummaryModel(summary *secHubModel.Summary) *models.SecuritySummary { + return &models.SecuritySummary{ + CriticalCnt: summary.CriticalCnt, + HighCnt: summary.HighCnt, + MediumCnt: summary.MediumCnt, + LowCnt: summary.LowCnt, + NoneCnt: summary.NoneCnt, + UnknownCnt: summary.UnknownCnt, + FixableCnt: summary.FixableCnt, + TotalVuls: summary.CriticalCnt + summary.HighCnt + summary.MediumCnt + summary.LowCnt + summary.NoneCnt + summary.UnknownCnt, + TotalArtifact: summary.TotalArtifactCnt, + ScannedCnt: summary.ScannedCnt, + DangerousCves: toDangerousCves(summary.DangerousCVEs), + DangerousArtifacts: toDangerousArtifacts(summary.DangerousArtifacts), + } +} +func toDangerousArtifacts(artifacts []*secHubModel.DangerousArtifact) []*models.DangerousArtifact { + var result []*models.DangerousArtifact + for _, artifact := range artifacts { + result = append(result, &models.DangerousArtifact{ + ProjectID: artifact.Project, + RepositoryName: artifact.Repository, + Digest: artifact.Digest, + CriticalCnt: artifact.CriticalCnt, + HighCnt: artifact.HighCnt, + MediumCnt: artifact.MediumCnt, + }) + } + return result +} + +func toDangerousCves(cves []*scan.VulnerabilityRecord) []*models.DangerousCVE { + var result []*models.DangerousCVE + for _, vul := range cves { + result = append(result, &models.DangerousCVE{ + CVEID: vul.CVEID, + Package: vul.Package, + Version: vul.PackageVersion, + Severity: vul.Severity, + CvssScoreV3: *vul.CVE3Score, + }) + } + return result +} diff --git a/src/testing/controller/controller.go b/src/testing/controller/controller.go index c982d9edb01..2daf186ad2b 100644 --- a/src/testing/controller/controller.go +++ b/src/testing/controller/controller.go @@ -35,3 +35,4 @@ package controller //go:generate mockery --case snake --dir ../../controller/task --name Controller --output ./task --outpkg task //go:generate mockery --case snake --dir ../../controller/task --name ExecutionController --output ./task --outpkg task //go:generate mockery --case snake --dir ../../controller/webhook --name Controller --output ./webhook --outpkg webhook +//go:generate mockery --case snake --dir ../../controller/securityhub --name Controller --output ./securityhub --outpkg securityhub diff --git a/src/testing/controller/securityhub/controller.go b/src/testing/controller/securityhub/controller.go new file mode 100644 index 00000000000..6c2f5cd8390 --- /dev/null +++ b/src/testing/controller/securityhub/controller.go @@ -0,0 +1,65 @@ +// Code generated by mockery v2.22.1. DO NOT EDIT. + +package securityhub + +import ( + context "context" + + model "github.com/goharbor/harbor/src/pkg/securityhub/model" + mock "github.com/stretchr/testify/mock" + + securityhub "github.com/goharbor/harbor/src/controller/securityhub" +) + +// Controller is an autogenerated mock type for the Controller type +type Controller struct { + mock.Mock +} + +// SecuritySummary provides a mock function with given fields: ctx, projectID, options +func (_m *Controller) SecuritySummary(ctx context.Context, projectID int64, options ...securityhub.Option) (*model.Summary, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, projectID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *model.Summary + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, ...securityhub.Option) (*model.Summary, error)); ok { + return rf(ctx, projectID, options...) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, ...securityhub.Option) *model.Summary); ok { + r0 = rf(ctx, projectID, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Summary) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, ...securityhub.Option) error); ok { + r1 = rf(ctx, projectID, options...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewController interface { + mock.TestingT + Cleanup(func()) +} + +// NewController creates a new instance of Controller. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewController(t mockConstructorTestingTNewController) *Controller { + mock := &Controller{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/src/testing/pkg/pkg.go b/src/testing/pkg/pkg.go index 08b92e82972..efef9799723 100644 --- a/src/testing/pkg/pkg.go +++ b/src/testing/pkg/pkg.go @@ -73,3 +73,4 @@ package pkg //go:generate mockery --case snake --dir ../../pkg/jobmonitor --name QueueManager --output ./jobmonitor --outpkg jobmonitor //go:generate mockery --case snake --dir ../../pkg/jobmonitor --name RedisClient --output ./jobmonitor --outpkg jobmonitor //go:generate mockery --case snake --dir ../../pkg/queuestatus --name Manager --output ./queuestatus --outpkg queuestatus +//go:generate mockery --case snake --dir ../../pkg/securityhub --name Manager --output ./securityhub --outpkg securityhub diff --git a/src/testing/pkg/securityhub/manager.go b/src/testing/pkg/securityhub/manager.go new file mode 100644 index 00000000000..cb06f31dfdb --- /dev/null +++ b/src/testing/pkg/securityhub/manager.go @@ -0,0 +1,136 @@ +// Code generated by mockery v2.22.1. DO NOT EDIT. + +package securityhub + +import ( + context "context" + + model "github.com/goharbor/harbor/src/pkg/securityhub/model" + mock "github.com/stretchr/testify/mock" + + q "github.com/goharbor/harbor/src/lib/q" + + scan "github.com/goharbor/harbor/src/pkg/scan/dao/scan" +) + +// Manager is an autogenerated mock type for the Manager type +type Manager struct { + mock.Mock +} + +// DangerousArtifacts provides a mock function with given fields: ctx, scannerUUID, projectID, query +func (_m *Manager) DangerousArtifacts(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*model.DangerousArtifact, error) { + ret := _m.Called(ctx, scannerUUID, projectID, query) + + var r0 []*model.DangerousArtifact + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) ([]*model.DangerousArtifact, error)); ok { + return rf(ctx, scannerUUID, projectID, query) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) []*model.DangerousArtifact); ok { + r0 = rf(ctx, scannerUUID, projectID, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.DangerousArtifact) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64, *q.Query) error); ok { + r1 = rf(ctx, scannerUUID, projectID, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DangerousCVEs provides a mock function with given fields: ctx, scannerUUID, projectID, query +func (_m *Manager) DangerousCVEs(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) ([]*scan.VulnerabilityRecord, error) { + ret := _m.Called(ctx, scannerUUID, projectID, query) + + var r0 []*scan.VulnerabilityRecord + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) ([]*scan.VulnerabilityRecord, error)); ok { + return rf(ctx, scannerUUID, projectID, query) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) []*scan.VulnerabilityRecord); ok { + r0 = rf(ctx, scannerUUID, projectID, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*scan.VulnerabilityRecord) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64, *q.Query) error); ok { + r1 = rf(ctx, scannerUUID, projectID, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ScannedArtifactsCount provides a mock function with given fields: ctx, scannerUUID, projectID, query +func (_m *Manager) ScannedArtifactsCount(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (int64, error) { + ret := _m.Called(ctx, scannerUUID, projectID, query) + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) (int64, error)); ok { + return rf(ctx, scannerUUID, projectID, query) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) int64); ok { + r0 = rf(ctx, scannerUUID, projectID, query) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64, *q.Query) error); ok { + r1 = rf(ctx, scannerUUID, projectID, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Summary provides a mock function with given fields: ctx, scannerUUID, projectID, query +func (_m *Manager) Summary(ctx context.Context, scannerUUID string, projectID int64, query *q.Query) (*model.Summary, error) { + ret := _m.Called(ctx, scannerUUID, projectID, query) + + var r0 *model.Summary + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) (*model.Summary, error)); ok { + return rf(ctx, scannerUUID, projectID, query) + } + if rf, ok := ret.Get(0).(func(context.Context, string, int64, *q.Query) *model.Summary); ok { + r0 = rf(ctx, scannerUUID, projectID, query) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Summary) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, int64, *q.Query) error); ok { + r1 = rf(ctx, scannerUUID, projectID, query) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewManager interface { + mock.TestingT + Cleanup(func()) +} + +// NewManager creates a new instance of Manager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewManager(t mockConstructorTestingTNewManager) *Manager { + mock := &Manager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}