fix: export cve adds resource check and project validation (#17265)

1. Add resource permission check for API handler
2. Validate export cve params project
3. Optimize friendly human message when execution status is error

Signed-off-by: chlins <chenyuzh@vmware.com>
This commit is contained in:
Chenyu Zhang 2022-07-29 19:01:46 +08:00 committed by GitHub
parent bd1d441b01
commit bff4e13087
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 133 additions and 43 deletions

View File

@ -9228,10 +9228,6 @@ definitions:
type: string type: string
x-omitempty: false x-omitempty: false
description: The status text description: The status text
job_name:
type: string
x-omitempty: false
description: The name of the job as specified by the user
user_name: user_name:
type: string type: string
x-omitempty: false x-omitempty: false

View File

@ -76,4 +76,5 @@ const (
ResourceScanAll = Resource("scan-all") ResourceScanAll = Resource("scan-all")
ResourceSystemVolumes = Resource("system-volumes") ResourceSystemVolumes = Resource("system-volumes")
ResourcePurgeAuditLog = Resource("purge-audit") ResourcePurgeAuditLog = Resource("purge-audit")
ResourceExportCVE = Resource("export-cve")
) )

View File

@ -124,6 +124,10 @@ var (
{Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionUpdate}, {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionUpdate},
{Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionDelete}, {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionDelete},
{Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionList}, {Resource: rbac.ResourcePreatPolicy, Action: rbac.ActionList},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionList},
}, },
"maintainer": { "maintainer": {
@ -208,6 +212,10 @@ var (
{Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate},
{Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionList},
}, },
"developer": { "developer": {
@ -270,6 +278,10 @@ var (
{Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionCreate},
{Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete}, {Resource: rbac.ResourceArtifactLabel, Action: rbac.ActionDelete},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionCreate},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionRead},
{Resource: rbac.ResourceExportCVE, Action: rbac.ActionList},
}, },
"guest": { "guest": {

View File

@ -114,6 +114,7 @@ func (c *controller) Start(ctx context.Context, request export.Request) (executi
logger := log.GetLogger(ctx) logger := log.GetLogger(ctx)
vendorID := int64(ctx.Value(export.CsvJobVendorIDKey).(int)) vendorID := int64(ctx.Value(export.CsvJobVendorIDKey).(int))
extraAttrs := make(map[string]interface{}) extraAttrs := make(map[string]interface{})
extraAttrs[export.ProjectIDsAttribute] = request.Projects
extraAttrs[export.JobNameAttribute] = request.JobName extraAttrs[export.JobNameAttribute] = request.JobName
extraAttrs[export.UserNameAttribute] = request.UserName extraAttrs[export.UserNameAttribute] = request.UserName
id, err := c.execMgr.Create(ctx, job.ScanDataExport, vendorID, task.ExecutionTriggerManual, extraAttrs) id, err := c.execMgr.Create(ctx, job.ScanDataExport, vendorID, task.ExecutionTriggerManual, extraAttrs)
@ -170,6 +171,11 @@ func (c *controller) convertToExportExecStatus(ctx context.Context, exec *task.E
StartTime: exec.StartTime, StartTime: exec.StartTime,
EndTime: exec.EndTime, EndTime: exec.EndTime,
} }
if pids, ok := exec.ExtraAttrs[export.ProjectIDsAttribute]; ok {
for _, pid := range pids.([]interface{}) {
execStatus.ProjectIDs = append(execStatus.ProjectIDs, int64(pid.(float64)))
}
}
if digest, ok := exec.ExtraAttrs[export.DigestKey]; ok { if digest, ok := exec.ExtraAttrs[export.DigestKey]; ok {
execStatus.ExportDataDigest = digest.(string) execStatus.ExportDataDigest = digest.(string)
} }

View File

@ -241,6 +241,7 @@ func (suite *ScanDataExportExecutionTestSuite) TestStart() {
{ {
// get execution succeeds // get execution succeeds
attrs := make(map[string]interface{}) attrs := make(map[string]interface{})
attrs[export.ProjectIDsAttribute] = []int64{1}
attrs[export.JobNameAttribute] = "test-job" attrs[export.JobNameAttribute] = "test-job"
attrs[export.UserNameAttribute] = "test-user" attrs[export.UserNameAttribute] = "test-user"
suite.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, attrs).Return(int64(10), nil) suite.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, attrs).Return(int64(10), nil)
@ -248,6 +249,7 @@ func (suite *ScanDataExportExecutionTestSuite) TestStart() {
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, export.CsvJobVendorIDKey, int(-1)) ctx = context.WithValue(ctx, export.CsvJobVendorIDKey, int(-1))
criteria := export.Request{} criteria := export.Request{}
criteria.Projects = []int64{1}
criteria.UserName = "test-user" criteria.UserName = "test-user"
criteria.JobName = "test-job" criteria.JobName = "test-job"
executionId, err := suite.ctl.Start(ctx, criteria) executionId, err := suite.ctl.Start(ctx, criteria)
@ -303,13 +305,14 @@ func (suite *ScanDataExportExecutionTestSuite) TestStartWithTaskManagerError() {
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, export.CsvJobVendorIDKey, int(-1)) ctx = context.WithValue(ctx, export.CsvJobVendorIDKey, int(-1))
attrs := make(map[string]interface{}) attrs := make(map[string]interface{})
attrs[export.ProjectIDsAttribute] = []int64{1}
attrs[export.JobNameAttribute] = "test-job" attrs[export.JobNameAttribute] = "test-job"
attrs[export.UserNameAttribute] = "test-user" attrs[export.UserNameAttribute] = "test-user"
suite.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, attrs).Return(int64(10), nil) suite.execMgr.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, attrs).Return(int64(10), nil)
suite.taskMgr.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(int64(-1), errors.New("Test Error")) suite.taskMgr.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(int64(-1), errors.New("Test Error"))
mock.OnAnything(suite.execMgr, "StopAndWait").Return(nil) mock.OnAnything(suite.execMgr, "StopAndWait").Return(nil)
mock.OnAnything(suite.execMgr, "MarkError").Return(nil) mock.OnAnything(suite.execMgr, "MarkError").Return(nil)
_, err := suite.ctl.Start(ctx, export.Request{JobName: "test-job", UserName: "test-user"}) _, err := suite.ctl.Start(ctx, export.Request{JobName: "test-job", UserName: "test-user", Projects: []int64{1}})
suite.Error(err) suite.Error(err)
} }
} }

View File

@ -4,13 +4,14 @@ package export
type CsvJobVendorID string type CsvJobVendorID string
const ( const (
JobNameAttribute = "job_name" ProjectIDsAttribute = "project_ids"
UserNameAttribute = "user_name" JobNameAttribute = "job_name"
ScanDataExportDir = "/var/scandata_exports" UserNameAttribute = "user_name"
QueryPageSize = 100000 ScanDataExportDir = "/var/scandata_exports"
ArtifactGroupSize = 10000 QueryPageSize = 100000
DigestKey = "artifact_digest" ArtifactGroupSize = 10000
CreateTimestampKey = "create_ts" DigestKey = "artifact_digest"
Vendor = "SCAN_DATA_EXPORT" CreateTimestampKey = "create_ts"
CsvJobVendorIDKey = CsvJobVendorID("vendorId") Vendor = "SCAN_DATA_EXPORT"
CsvJobVendorIDKey = CsvJobVendorID("vendorId")
) )

View File

@ -78,6 +78,8 @@ type Execution struct {
ID int64 ID int64
// UserID triggering the execution // UserID triggering the execution
UserID int64 UserID int64
// ProjectIDs contains projects ids
ProjectIDs []int64
// Status provides the status of the execution // Status provides the status of the execution
Status string Status string
// StatusMessage contains the human-readable status message for the execution // StatusMessage contains the human-readable status message for the execution

View File

@ -15,7 +15,7 @@ import (
"github.com/goharbor/harbor/src/common/rbac" "github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/controller/scandataexport" "github.com/goharbor/harbor/src/controller/scandataexport"
"github.com/goharbor/harbor/src/jobservice/logger" "github.com/goharbor/harbor/src/jobservice/job"
"github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/lib/log"
"github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/orm"
"github.com/goharbor/harbor/src/pkg/scan/export" "github.com/goharbor/harbor/src/pkg/scan/export"
@ -46,7 +46,19 @@ func (se *scanDataExportAPI) Prepare(ctx context.Context, operation string, para
} }
func (se *scanDataExportAPI) ExportScanData(ctx context.Context, params operation.ExportScanDataParams) middleware.Responder { func (se *scanDataExportAPI) ExportScanData(ctx context.Context, params operation.ExportScanDataParams) middleware.Responder {
if err := se.RequireAuthenticated(ctx); err != nil { // validate project id, currently we only support single project
criteria := params.Criteria
if criteria == nil {
err := errors.New(errors.Errorf("criteria is invalid: %v", criteria)).WithCode(errors.BadRequestCode)
return se.SendError(ctx, err)
}
if len(criteria.Projects) != 1 {
err := errors.New(errors.Errorf("only support export single project, invalid value: %v", criteria.Projects)).WithCode(errors.BadRequestCode)
return se.SendError(ctx, err)
}
if err := se.RequireProjectAccess(ctx, criteria.Projects[0], rbac.ActionCreate, rbac.ResourceExportCVE); err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
@ -57,17 +69,6 @@ func (se *scanDataExportAPI) ExportScanData(ctx context.Context, params operatio
return operation.NewExportScanDataBadRequest().WithPayload(errors) return operation.NewExportScanDataBadRequest().WithPayload(errors)
} }
// loop through the list of projects and validate that scan privilege and create privilege
// is available for all projects
// TODO : Should we just ignore projects that do not have the required level of access?
projects := params.Criteria.Projects
for _, project := range projects {
if err := se.RequireProjectAccess(ctx, project, rbac.ActionCreate, rbac.ResourceScan); err != nil {
return se.SendError(ctx, err)
}
}
scanDataExportJob := new(models.ScanDataExportJob) scanDataExportJob := new(models.ScanDataExportJob)
secContext, err := se.GetSecurityContext(ctx) secContext, err := se.GetSecurityContext(ctx)
@ -104,12 +105,17 @@ func (se *scanDataExportAPI) ExportScanData(ctx context.Context, params operatio
} }
func (se *scanDataExportAPI) GetScanDataExportExecution(ctx context.Context, params operation.GetScanDataExportExecutionParams) middleware.Responder { func (se *scanDataExportAPI) GetScanDataExportExecution(ctx context.Context, params operation.GetScanDataExportExecutionParams) middleware.Responder {
err := se.RequireAuthenticated(ctx) if err := se.RequireAuthenticated(ctx); err != nil {
return se.SendError(ctx, err)
}
execution, err := se.scanDataExportCtl.GetExecution(ctx, params.ExecutionID)
if err != nil { if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
execution, err := se.scanDataExportCtl.GetExecution(ctx, params.ExecutionID)
if err != nil { // check the permission by project ids in execution
if err = se.requireProjectsAccess(ctx, execution.ProjectIDs, rbac.ActionRead, rbac.ResourceExportCVE); err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
@ -134,7 +140,6 @@ func (se *scanDataExportAPI) GetScanDataExportExecution(ctx context.Context, par
StatusText: execution.StatusMessage, StatusText: execution.StatusMessage,
Trigger: execution.Trigger, Trigger: execution.Trigger,
UserID: execution.UserID, UserID: execution.UserID,
JobName: execution.JobName,
UserName: execution.UserName, UserName: execution.UserName,
FilePresent: execution.FilePresent, FilePresent: execution.FilePresent,
} }
@ -143,12 +148,11 @@ func (se *scanDataExportAPI) GetScanDataExportExecution(ctx context.Context, par
} }
func (se *scanDataExportAPI) DownloadScanData(ctx context.Context, params operation.DownloadScanDataParams) middleware.Responder { func (se *scanDataExportAPI) DownloadScanData(ctx context.Context, params operation.DownloadScanDataParams) middleware.Responder {
err := se.RequireAuthenticated(ctx) if err := se.RequireAuthenticated(ctx); err != nil {
if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
execution, err := se.scanDataExportCtl.GetExecution(ctx, params.ExecutionID)
execution, err := se.scanDataExportCtl.GetExecution(ctx, params.ExecutionID)
if err != nil { if err != nil {
if notFound := orm.AsNotFoundError(err, "execution with id: %d not found", params.ExecutionID); notFound != nil { if notFound := orm.AsNotFoundError(err, "execution with id: %d not found", params.ExecutionID); notFound != nil {
return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) { return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) {
@ -158,6 +162,11 @@ func (se *scanDataExportAPI) DownloadScanData(ctx context.Context, params operat
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
// check the permission by project ids in execution
if err = se.requireProjectsAccess(ctx, execution.ProjectIDs, rbac.ActionRead, rbac.ResourceExportCVE); err != nil {
return se.SendError(ctx, err)
}
// check if the CSV artifact for the execution exists // check if the CSV artifact for the execution exists
if !execution.FilePresent { if !execution.FilePresent {
return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) { return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) {
@ -182,7 +191,7 @@ func (se *scanDataExportAPI) DownloadScanData(ctx context.Context, params operat
if err != nil { if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
logger.Infof("reading data from file : %s", repositoryName) log.Infof("reading data from file : %s", repositoryName)
return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) { return middleware.ResponderFunc(func(writer http.ResponseWriter, producer runtime.Producer) {
defer se.cleanUpArtifact(ctx, repositoryName, execution.ExportDataDigest, params.ExecutionID, file) defer se.cleanUpArtifact(ctx, repositoryName, execution.ExportDataDigest, params.ExecutionID, file)
@ -191,27 +200,29 @@ func (se *scanDataExportAPI) DownloadScanData(ctx context.Context, params operat
writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fmt.Sprintf("%s.csv", repositoryName))) writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fmt.Sprintf("%s.csv", repositoryName)))
nbytes, err := io.Copy(writer, file) nbytes, err := io.Copy(writer, file)
if err != nil { if err != nil {
logger.Errorf("Encountered error while copying data: %v", err) log.Errorf("Encountered error while copying data: %v", err)
} else { } else {
logger.Debugf("Copied %v bytes from file to client", nbytes) log.Debugf("Copied %v bytes from file to client", nbytes)
} }
}) })
} }
func (se *scanDataExportAPI) GetScanDataExportExecutionList(ctx context.Context, params operation.GetScanDataExportExecutionListParams) middleware.Responder { func (se *scanDataExportAPI) GetScanDataExportExecutionList(ctx context.Context, params operation.GetScanDataExportExecutionListParams) middleware.Responder {
err := se.RequireAuthenticated(ctx) if err := se.RequireAuthenticated(ctx); err != nil {
if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
secContext, err := se.GetSecurityContext(ctx)
secContext, err := se.GetSecurityContext(ctx)
if err != nil { if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
executions, err := se.scanDataExportCtl.ListExecutions(ctx, secContext.GetUsername()) executions, err := se.scanDataExportCtl.ListExecutions(ctx, secContext.GetUsername())
if err != nil { if err != nil {
return se.SendError(ctx, err) return se.SendError(ctx, err)
} }
// projectSet store the unrepeated project ids
projectSet := make(map[int64]struct{})
execs := make([]*models.ScanDataExportExecution, 0) execs := make([]*models.ScanDataExportExecution, 0)
for _, execution := range executions { for _, execution := range executions {
sdeExec := &models.ScanDataExportExecution{ sdeExec := &models.ScanDataExportExecution{
@ -223,11 +234,30 @@ func (se *scanDataExportAPI) GetScanDataExportExecutionList(ctx context.Context,
Trigger: execution.Trigger, Trigger: execution.Trigger,
UserID: execution.UserID, UserID: execution.UserID,
UserName: execution.UserName, UserName: execution.UserName,
JobName: execution.JobName,
FilePresent: execution.FilePresent, FilePresent: execution.FilePresent,
} }
// add human friendly message when status is error
if sdeExec.Status == job.ErrorStatus.String() {
sdeExec.StatusText = "Please contact the system administrator to check the logs of jobservice."
}
// store project ids
for _, pid := range execution.ProjectIDs {
projectSet[pid] = struct{}{}
}
execs = append(execs, sdeExec) execs = append(execs, sdeExec)
} }
// convert projectSet to pids
var pids []int64
for pid := range projectSet {
pids = append(pids, pid)
}
// check the permission by project ids in execution
if err = se.requireProjectsAccess(ctx, pids, rbac.ActionRead, rbac.ResourceExportCVE); err != nil {
return se.SendError(ctx, err)
}
sdeExecList := models.ScanDataExportExecutionList{Items: execs} sdeExecList := models.ScanDataExportExecutionList{Items: execs}
return operation.NewGetScanDataExportExecutionListOK().WithPayload(&sdeExecList) return operation.NewGetScanDataExportExecutionListOK().WithPayload(&sdeExecList)
} }
@ -247,7 +277,7 @@ func (se *scanDataExportAPI) convertToCriteria(requestCriteria *models.ScanDataE
func (se *scanDataExportAPI) cleanUpArtifact(ctx context.Context, repositoryName, digest string, execID int64, file io.ReadCloser) { func (se *scanDataExportAPI) cleanUpArtifact(ctx context.Context, repositoryName, digest string, execID int64, file io.ReadCloser) {
file.Close() file.Close()
logger.Infof("Deleting report artifact : %v:%v", repositoryName, digest) log.Infof("Deleting report artifact : %v:%v", repositoryName, digest)
// the entire delete operation is executed within a transaction to ensure that any failures // the entire delete operation is executed within a transaction to ensure that any failures
// during the blob creation or tracking record creation result in a rollback of the transaction // during the blob creation or tracking record creation result in a rollback of the transaction
@ -270,3 +300,15 @@ func (se *scanDataExportAPI) cleanUpArtifact(ctx context.Context, repositoryName
log.Errorf("Error deleting system artifact record for %s/%s/%s: %v", vendor, repositoryName, digest, err) log.Errorf("Error deleting system artifact record for %s/%s/%s: %v", vendor, repositoryName, digest, err)
} }
} }
func (se *scanDataExportAPI) requireProjectsAccess(ctx context.Context, pids []int64, action rbac.Action, subresource ...rbac.Resource) error {
// check project permission one by one, return error if any project cannot
// access permission.
for _, pid := range pids {
if err := se.RequireProjectAccess(ctx, pid, action, subresource...); err != nil {
return err
}
}
return nil
}

View File

@ -75,6 +75,7 @@ func (suite *ScanExportTestSuite) TestAuthorization() {
{http.MethodGet, "/export/cve/download/100", nil, nil}, {http.MethodGet, "/export/cve/download/100", nil, nil},
} }
suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(false).Times(3)
suite.Security.On("IsAuthenticated").Return(false).Times(3) suite.Security.On("IsAuthenticated").Return(false).Times(3)
for _, req := range reqs { for _, req := range reqs {
@ -134,6 +135,7 @@ func (suite *ScanExportTestSuite) TestExportScanData() {
// user authenticated but incorrect/unsupported header sent across // user authenticated but incorrect/unsupported header sent across
{ {
suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Once()
suite.Security.On("IsAuthenticated").Return(true).Once() suite.Security.On("IsAuthenticated").Return(true).Once()
url := "/export/cve" url := "/export/cve"
@ -157,6 +159,32 @@ func (suite *ScanExportTestSuite) TestExportScanData() {
suite.Equal(nil, err) suite.Equal(nil, err)
} }
// should return 400 if project id number is not one
{
suite.Security.On("Can", mock.Anything, mock.Anything, mock.Anything).Return(true).Once()
suite.Security.On("IsAuthenticated").Return(true).Once()
url := "/export/cve"
criteria := models.ScanDataExportRequest{
CVEIds: "CVE-123",
Labels: []int64{100},
Projects: []int64{200, 300},
Repositories: "test-repo",
Tags: "{test-tag1, test-tag2}",
}
data, err := json.Marshal(criteria)
buffer := bytes.NewBuffer(data)
headers := make(map[string]string)
headers["X-Scan-Data-Type"] = v1.MimeTypeGenericVulnerabilityReport
mock.OnAnything(suite.scanExportCtl, "Start").Return(int64(100), nil).Once()
res, err := suite.DoReq(http.MethodPost, url, buffer, headers)
suite.Equal(400, res.StatusCode)
suite.Equal(nil, err)
}
} }
func (suite *ScanExportTestSuite) TestExportScanDataGetUserIdError() { func (suite *ScanExportTestSuite) TestExportScanDataGetUserIdError() {
@ -279,7 +307,6 @@ func (suite *ScanExportTestSuite) TestGetScanDataExportExecution() {
respData := models.ScanDataExportExecution{} respData := models.ScanDataExportExecution{}
json.NewDecoder(res.Body).Decode(&respData) json.NewDecoder(res.Body).Decode(&respData)
suite.Equal("test-user", respData.UserName) suite.Equal("test-user", respData.UserName)
suite.Equal("test-job", respData.JobName)
suite.Equal(false, respData.FilePresent) suite.Equal(false, respData.FilePresent)
} }