Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/controller/scan/base_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
45 changes: 45 additions & 0 deletions src/controller/scan/base_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/controller/scan/callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions src/controller/scan/scope.go
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
abstract postSchedule(param, scopeHeader?: string): Observable<any>;

abstract putSchedule(param): Observable<any>;
abstract putSchedule(param, scopeHeader?: string): Observable<any>;

abstract getSchedule(): Observable<any>;
}
Expand All @@ -31,15 +31,15 @@ export class ScanApiDefaultRepository extends ScanApiRepository {
super();
}

public postSchedule(param): Observable<any> {
public postSchedule(param, scopeHeader?: string): Observable<any> {
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<any> {
public putSchedule(param, scopeHeader?: string): Observable<any> {
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)));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,26 @@ export class ScanAllRepoService {
return this.scanApiRepository.getSchedule();
}

public postSchedule(type, cron): Observable<any> {
public postSchedule(type, cron, scopeHeader?: string): Observable<any> {
let param = {
schedule: {
type: type,
cron: cron,
},
};

return this.scanApiRepository.postSchedule(param);
return this.scanApiRepository.postSchedule(param, scopeHeader);
}

public putSchedule(type, cron): Observable<any> {
public putSchedule(type, cron, scopeHeader?: string): Observable<any> {
let param = {
schedule: {
type: type,
cron: cron,
},
};

return this.scanApiRepository.putSchedule(param);
return this.scanApiRepository.putSchedule(param, scopeHeader);
}
getMetrics(): Observable<ScanningMetrics> {
return this.http
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
(inputvalue)="saveSchedule($event)"></cron-selection>
</div>
</section>
<section class="form-block">
<label class="update-time">Scan scope (optional)</label>
<div class="form-group">
<label>Select projects to include</label>
<select multiple class="clr-input" [(ngModel)]="selectedProjectIds" (change)="loadRepositories()">
<option *ngFor="let p of projects" [ngValue]="p.project_id">{{ p.name }}</option>
</select>
<div class="hint mt-1">Leave empty to scan all projects.</div>
</div>
<div class="form-group">
<label>Select repositories (optional)</label>
<select multiple class="clr-input" [(ngModel)]="selectedRepositories">
<option *ngFor="let r of repositories" [ngValue]="r.name">{{ r.name }}</option>
</select>
<div class="hint mt-1">If repositories selected, they take precedence over project selection.</div>
</div>
</section>
<div class="clr-col-5">
<div class="clr-row-3">
<div class="btn-scan-right btn-scan margin-top-16px">
Expand Down
Loading