diff --git a/src/controller/scan/base_controller.go b/src/controller/scan/base_controller.go index 3bce22dbde0..93ae93058b9 100644 --- a/src/controller/scan/base_controller.go +++ b/src/controller/scan/base_controller.go @@ -387,6 +387,10 @@ func (bc *basicController) ScanAll(ctx context.Context, trigger string, async bo if op := operator.FromContext(ctx); op != "" { extra["operator"] = op } + // propagate optional scan-all scope from context into execution extra attrs + if scope := FromContextScope(ctx); scope != nil { + extra["scope"] = scope + } executionID, err := bc.execMgr.Create(ctx, job.ScanAllVendorType, 0, trigger, extra) if err != nil { return 0, err @@ -459,6 +463,72 @@ func (bc *basicController) isScanAllStopped(ctx context.Context, execID int64) b func (bc *basicController) startScanAll(ctx context.Context, executionID int64) error { batchSize := 50 + // Build optional artifact query based on stored scope on execution + var artQuery *q.Query + if exec, err := bc.execMgr.Get(ctx, executionID); err == nil && exec != nil { + if exec.ExtraAttrs != nil { + if s, ok := exec.ExtraAttrs["scope"].(map[string]any); ok { + artQuery = &q.Query{Keywords: map[string]any{}} + // project ids + if arr, ok := s["project_ids"].([]any); ok { + vals := make([]any, 0, len(arr)) + for _, v := range arr { + switch t := v.(type) { + case float64: + vals = append(vals, int64(t)) + case int64: + vals = append(vals, t) + } + } + if len(vals) > 0 { + artQuery.Keywords["ProjectID"] = &q.OrList{Values: vals} + } + } + if arr, ok := s["ProjectIDs"].([]any); ok && artQuery.Keywords["ProjectID"] == nil { + vals := make([]any, 0, len(arr)) + for _, v := range arr { + switch t := v.(type) { + case float64: + vals = append(vals, int64(t)) + case int64: + vals = append(vals, t) + } + } + if len(vals) > 0 { + artQuery.Keywords["ProjectID"] = &q.OrList{Values: vals} + } + } + // repositories + if arr, ok := s["repositories"].([]any); ok { + vals := make([]any, 0, len(arr)) + for _, v := range arr { + if name, ok := v.(string); ok { + vals = append(vals, name) + } + } + if len(vals) > 0 { + artQuery.Keywords["RepositoryName"] = &q.OrList{Values: vals} + } + } + if arr, ok := s["Repositories"].([]any); ok && artQuery.Keywords["RepositoryName"] == nil { + vals := make([]any, 0, len(arr)) + for _, v := range arr { + if name, ok := v.(string); ok { + vals = append(vals, name) + } + } + if len(vals) > 0 { + artQuery.Keywords["RepositoryName"] = &q.OrList{Values: vals} + } + } + // if query is empty, keep as nil to scan all + if len(artQuery.Keywords) == 0 { + artQuery = nil + } + } + } + } + summary := struct { TotalCount int `json:"total_count"` SubmitCount int `json:"submit_count"` diff --git a/src/controller/scan/base_controller_test.go b/src/controller/scan/base_controller_test.go index 93235fbc717..87c1a146bd7 100644 --- a/src/controller/scan/base_controller_test.go +++ b/src/controller/scan/base_controller_test.go @@ -657,6 +657,51 @@ func (suite *ControllerTestSuite) TestScanAll() { } } +func (suite *ControllerTestSuite) TestScanAllWithScope() { + executionID := int64(2) + scope := &ScanAllScope{ + ProjectIDs: []int64{1}, + Repositories: []string{"library/test"}, + } + + // Reset mock to avoid pollution + suite.execMgr.ExpectedCalls = nil + suite.execMgr.Calls = nil + + // Prepare context with scope + ctx := WithScanAllScope(context.TODO(), scope) + + // Mock expectations + // Verify that Create is called with extraAttrs containing the scope + var capturedExtra map[string]any + suite.execMgr.On("Create", ctx, "SCAN_ALL", int64(0), "MANUAL", mock.Anything).Run(func(args mock.Arguments) { + capturedExtra = args.Get(4).(map[string]any) + }).Return(executionID, nil).Once() + + // Other necessary mocks for flow to complete + mock.OnAnything(suite.scanHandler, "MakePlaceHolder").Return([]*scan.Report{{UUID: "uuid"}}, nil).Once() + mock.OnAnything(suite.scanHandler, "RequiredPermissions").Return([]*types.Policy{}).Once() + mock.OnAnything(suite.ar, "HasUnscannableLayer").Return(false, nil).Once() + suite.execMgr.On("Get", mock.Anything, executionID).Return(&task.Execution{ID: executionID}, nil).Times(2) + mock.OnAnything(suite.accessoryMgr, "List").Return([]accessoryModel.Accessory{}, nil).Once() + mock.OnAnything(suite.artifactCtl, "List").Return([]*artifact.Artifact{}, nil).Once() + suite.taskMgr.On("Count", ctx, q.New(q.KeyWords{"execution_id": executionID})).Return(int64(0), nil).Once() + mock.OnAnything(suite.execMgr, "UpdateExtraAttrs").Return(nil).Once() + suite.execMgr.On("MarkDone", mock.Anything, executionID, mock.Anything).Return(nil).Once() + suite.cache.On("Contains", ctx, scanAllStoppedKey(executionID)).Return(false).Once() + + _, err := suite.c.ScanAll(ctx, "MANUAL", false) + suite.NoError(err) + + // Verify scope was passed + assert.NotNil(suite.T(), capturedExtra) + if capturedExtra != nil { + s, ok := capturedExtra["scope"] + assert.True(suite.T(), ok) + assert.Equal(suite.T(), scope, s) + } +} + func (suite *ControllerTestSuite) TestStopScanAll() { mockExecID := int64(100) // mock error case diff --git a/src/controller/scan/callback.go b/src/controller/scan/callback.go index 211b2363829..3a1682ebcd4 100644 --- a/src/controller/scan/callback.go +++ b/src/controller/scan/callback.go @@ -72,6 +72,31 @@ func scanAllCallback(ctx context.Context, param string) error { if op, ok := params["operator"].(string); ok { ctx = context.WithValue(ctx, operator.ContextKey{}, op) } + + // optional: scope + if s, ok := params["scope"].(map[string]any); ok { + var scope ScanAllScope + // project_ids + if arr, ok := s["project_ids"].([]any); ok { + for _, v := range arr { + switch t := v.(type) { + case float64: + scope.ProjectIDs = append(scope.ProjectIDs, int64(t)) + case int64: + scope.ProjectIDs = append(scope.ProjectIDs, t) + } + } + } + // repositories + if arr, ok := s["repositories"].([]any); ok { + for _, v := range arr { + if name, ok := v.(string); ok { + scope.Repositories = append(scope.Repositories, name) + } + } + } + ctx = WithScanAllScope(ctx, &scope) + } } _, err := scanCtl.ScanAll(ctx, task.ExecutionTriggerSchedule, true) diff --git a/src/controller/scan/scope.go b/src/controller/scan/scope.go new file mode 100644 index 00000000000..5fc0880d39a --- /dev/null +++ b/src/controller/scan/scope.go @@ -0,0 +1,52 @@ +// 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 scan + +import "context" + +// ScopeHeader is the HTTP header key used to carry scan-all scope JSON. +const ScopeHeader = "X-Scan-All-Scope" + +// ScanAllScope defines optional filters for scan-all. +// Currently supports filtering by project IDs or repository names. +// Leave all fields empty to scan everything (default behavior). +type ScanAllScope struct { + ProjectIDs []int64 `json:"project_ids,omitempty"` + Repositories []string `json:"repositories,omitempty"` +} + +// scopeCtxKey is the context key type for storing scope in context +// to avoid collisions. +type scopeCtxKey struct{} + +// WithScanAllScope returns a new context with the given scope. +func WithScanAllScope(ctx context.Context, scope *ScanAllScope) context.Context { + if scope == nil { + return ctx + } + return context.WithValue(ctx, scopeCtxKey{}, scope) +} + +// FromContextScope returns the ScanAllScope from context if present. +func FromContextScope(ctx context.Context) *ScanAllScope { + v := ctx.Value(scopeCtxKey{}) + if v == nil { + return nil + } + if s, ok := v.(*ScanAllScope); ok { + return s + } + return nil +} diff --git a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.api.repository.ts b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.api.repository.ts index 4270fca1277..d678ecc074a 100644 --- a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.api.repository.ts +++ b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.api.repository.ts @@ -12,15 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { throwError as observableThrowError, Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CURRENT_BASE_HREF } from '../../../../shared/units/utils'; export abstract class ScanApiRepository { - abstract postSchedule(param): Observable; + abstract postSchedule(param, scopeHeader?: string): Observable; - abstract putSchedule(param): Observable; + abstract putSchedule(param, scopeHeader?: string): Observable; abstract getSchedule(): Observable; } @@ -31,15 +31,15 @@ export class ScanApiDefaultRepository extends ScanApiRepository { super(); } - public postSchedule(param): Observable { +public postSchedule(param, scopeHeader?: string): Observable { return this.http - .post(`${CURRENT_BASE_HREF}/system/scanAll/schedule`, param) +.post(`${CURRENT_BASE_HREF}/system/scanAll/schedule`, param, scopeHeader ? { headers: new HttpHeaders({ 'X-Scan-All-Scope': scopeHeader }) } : undefined) .pipe(catchError(error => observableThrowError(error))); } - public putSchedule(param): Observable { +public putSchedule(param, scopeHeader?: string): Observable { return this.http - .put(`${CURRENT_BASE_HREF}/system/scanAll/schedule`, param) +.put(`${CURRENT_BASE_HREF}/system/scanAll/schedule`, param, scopeHeader ? { headers: new HttpHeaders({ 'X-Scan-All-Scope': scopeHeader }) } : undefined) .pipe(catchError(error => observableThrowError(error))); } diff --git a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.service.ts b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.service.ts index ab90763a33b..f5263c20753 100644 --- a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.service.ts +++ b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/scanAll.service.ts @@ -40,7 +40,7 @@ export class ScanAllRepoService { return this.scanApiRepository.getSchedule(); } - public postSchedule(type, cron): Observable { + public postSchedule(type, cron, scopeHeader?: string): Observable { let param = { schedule: { type: type, @@ -48,10 +48,10 @@ export class ScanAllRepoService { }, }; - return this.scanApiRepository.postSchedule(param); + return this.scanApiRepository.postSchedule(param, scopeHeader); } - public putSchedule(type, cron): Observable { + public putSchedule(type, cron, scopeHeader?: string): Observable { let param = { schedule: { type: type, @@ -59,7 +59,7 @@ export class ScanAllRepoService { }, }; - return this.scanApiRepository.putSchedule(param); + return this.scanApiRepository.putSchedule(param, scopeHeader); } getMetrics(): Observable { return this.http diff --git a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.html b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.html index 4a6731dba55..81af71726d4 100644 --- a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.html +++ b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.html @@ -19,6 +19,23 @@ (inputvalue)="saveSchedule($event)"> +
+ +
+ + +
Leave empty to scan all projects.
+
+
+ + +
If repositories selected, they take precedence over project selection.
+
+
diff --git a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.ts b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.ts index 34b500b9800..52011559f5e 100644 --- a/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.ts +++ b/src/portal/src/app/base/left-side-nav/interrogation-services/vulnerability/vulnerability-config.component.ts @@ -24,6 +24,10 @@ import { VULNERABILITY_SCAN_STATUS, } from '../../../../shared/units/utils'; import { DatePipe } from '@angular/common'; +import { Project } from '../../../project/project-config/project-policy-config/project'; +import { Repository as NewRepository } from '../../../../../../ng-swagger-gen/models/repository'; +import { RepositoryService as NewRepositoryService } from '../../../../../../ng-swagger-gen/services/repository.service'; +import { ProjectService } from '../../../../shared/services'; import { errorHandler } from '../../../../shared/units/shared.utils'; import { ScanAllService } from '../../../../../../ng-swagger-gen/services/scan-all.service'; @@ -65,7 +69,9 @@ export class VulnerabilityConfigComponent implements OnInit, OnDestroy { private scanningService: ScanAllRepoService, private newScanAllService: ScanAllService, private errorHandlerEntity: ErrorHandler, - private translate: TranslateService + private translate: TranslateService, + private projectService: ProjectService, + private newRepoService: NewRepositoryService ) {} get scanningMetrics(): ScanningMetrics { @@ -177,9 +183,23 @@ export class VulnerabilityConfigComponent implements OnInit, OnDestroy { } } + // Scope selection for selective scanning + projects: Project[] = []; + selectedProjectIds: number[] = []; + repositories: NewRepository[] = []; + selectedRepositories: string[] = []; + ngOnInit(): void { this.getScanText(); this.getScanners(); + // load first page of projects (fetch all via pagination if needed could be added later) + this.projectService + .listProjects('', undefined, 1, 100) + .subscribe(res => { + this.projects = (res && res.body) || []; + }); + // reload repos when project selection changes (simple polling via setter would be better in a full refactor) + // here we rely on explicit call before saving } ngOnDestroy() { @@ -346,6 +366,39 @@ export class VulnerabilityConfigComponent implements OnInit, OnDestroy { }; } + loadRepositories() { + this.repositories = []; + this.selectedRepositories = []; + if (!this.selectedProjectIds || this.selectedProjectIds.length === 0) { + return; + } + const pageSize = 100; + this.selectedProjectIds.forEach(pid => { + const proj = this.projects?.find(p => p.project_id === pid); + if (!proj) { return; } + this.newRepoService + .listRepositories({ projectName: proj.name, page: 1, pageSize }) + .subscribe(list => { + if (list && list.length) { + this.repositories = this.repositories.concat(list); + } + }); + }); + } + + private buildScopeHeader(): string | undefined { + // Prefer repo list if specified, else fall back to projects + if (this.selectedRepositories && this.selectedRepositories.length) { + try { + return JSON.stringify({ repositories: this.selectedRepositories }); + } catch { return undefined; } + } + if (this.selectedProjectIds && this.selectedProjectIds.length) { + try { return JSON.stringify({ project_ids: this.selectedProjectIds }); } catch { return undefined; } + } + return undefined; + } + saveSchedule(cron: string): void { let schedule = this.schedule; if ( @@ -353,8 +406,9 @@ export class VulnerabilityConfigComponent implements OnInit, OnDestroy { schedule.schedule && schedule.schedule.type !== SCHEDULE_TYPE_NONE ) { + const scopeHeader = this.buildScopeHeader(); this.scanningService - .putSchedule(this.CronScheduleComponent.scheduleType, cron) + .putSchedule(this.CronScheduleComponent.scheduleType, cron, scopeHeader) .subscribe( response => { this.translate @@ -370,8 +424,9 @@ export class VulnerabilityConfigComponent implements OnInit, OnDestroy { } ); } else { + const scopeHeader = this.buildScopeHeader(); this.scanningService - .postSchedule(this.CronScheduleComponent.scheduleType, cron) + .postSchedule(this.CronScheduleComponent.scheduleType, cron, scopeHeader) .subscribe( response => { this.translate diff --git a/src/server/v2.0/handler/scan_all.go b/src/server/v2.0/handler/scan_all.go index 0e0d7388127..7ef8dbba24a 100644 --- a/src/server/v2.0/handler/scan_all.go +++ b/src/server/v2.0/handler/scan_all.go @@ -16,6 +16,7 @@ package handler import ( "context" + "encoding/json" "fmt" "strings" @@ -90,6 +91,18 @@ func (s *scanAllAPI) CreateScanAllSchedule(ctx context.Context, params operation req := params.Schedule + // parse optional scope header + var scope any + if params.HTTPRequest != nil { + if h := params.HTTPRequest.Header.Get(scan.ScopeHeader); h != "" { + // validate JSON + var tmp map[string]any + if err := json.Unmarshal([]byte(h), &tmp); err == nil { + scope = tmp + } + } + } + if req.Schedule.Type == ScheduleNone { return operation.NewCreateScanAllScheduleCreated() } @@ -105,6 +118,14 @@ func (s *scanAllAPI) CreateScanAllSchedule(ctx context.Context, params operation return s.SendError(ctx, errors.ConflictError(nil).WithMessage(message)) } + // attach scope to context for manual trigger + if scope != nil { + // best-effort parse + b, _ := json.Marshal(scope) + var sscope scan.ScanAllScope + _ = json.Unmarshal(b, &sscope) + ctx = scan.WithScanAllScope(ctx, &sscope) + } if _, err := s.scanCtl.ScanAll(ctx, task.ExecutionTriggerManual, true); err != nil { return s.SendError(ctx, err) } @@ -119,7 +140,14 @@ func (s *scanAllAPI) CreateScanAllSchedule(ctx context.Context, params operation return s.SendError(ctx, errors.PreconditionFailedError(nil).WithMessage(message)) } - if _, err := s.createOrUpdateScanAllSchedule(ctx, req.Schedule.Type, req.Schedule.Cron, nil); err != nil { + cbParams := map[string]any{ + // the operator of schedule job is harbor-jobservice + "operator": secret.JobserviceUser, + } + if scope != nil { + cbParams["scope"] = scope + } + if _, err := s.scheduler.Schedule(ctx, job.ScanAllVendorType, 0, req.Schedule.Type, req.Schedule.Cron, scan.ScanAllCallback, cbParams, nil); err != nil { return s.SendError(ctx, err) } } @@ -133,6 +161,17 @@ func (s *scanAllAPI) UpdateScanAllSchedule(ctx context.Context, params operation } req := params.Schedule + // parse optional scope header + var scope any + if params.HTTPRequest != nil { + if h := params.HTTPRequest.Header.Get(scan.ScopeHeader); h != "" { + var tmp map[string]any + if err := json.Unmarshal([]byte(h), &tmp); err == nil { + scope = tmp + } + } + } + if req.Schedule.Type == ScheduleManual { return s.SendError(ctx, errors.BadRequestError(nil).WithMessagef("fail to update scan all schedule as wrong schedule type: %s", req.Schedule.Type)) } @@ -147,7 +186,19 @@ func (s *scanAllAPI) UpdateScanAllSchedule(ctx context.Context, params operation err = s.scheduler.UnScheduleByID(ctx, schedule.ID) } } else { - _, err = s.createOrUpdateScanAllSchedule(ctx, req.Schedule.Type, req.Schedule.Cron, schedule) + // update with new cron and optional scope by re-scheduling + if schedule != nil { + if err := s.scheduler.UnScheduleByID(ctx, schedule.ID); err != nil { + return s.SendError(ctx, err) + } + } + cbParams := map[string]any{ + "operator": secret.JobserviceUser, + } + if scope != nil { + cbParams["scope"] = scope + } + _, err = s.scheduler.Schedule(ctx, job.ScanAllVendorType, 0, req.Schedule.Type, req.Schedule.Cron, scan.ScanAllCallback, cbParams, nil) } if err != nil {