SBOM UI feature implementation (#19946)

* draft: sbom UI feature implementation

Signed-off-by: xuelichao <xuel@vmware.com>

* refactor based on swagger yaml changes

Signed-off-by: xuelichao <xuel@vmware.com>

* update scan type for scan and stop sbom request

Signed-off-by: xuelichao <xuel@vmware.com>

---------

Signed-off-by: xuelichao <xuel@vmware.com>
This commit is contained in:
Lichao Xue 2024-04-18 16:22:11 +08:00 committed by GitHub
parent 4fd11ce072
commit e8907a47ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1222 additions and 167 deletions

View File

@ -281,4 +281,13 @@ describe('CreateEditRuleComponent (inline template)', () => {
expect(ruleNameInput).toBeTruthy();
expect(ruleNameInput.value.trim()).toEqual('sync_01');
}));
it('List all Registries Response', fakeAsync(() => {
fixture.detectChanges();
comp.ngOnInit();
comp.getAllRegistries();
fixture.whenStable();
tick(5000);
expect(comp.targetList.length).toBe(4);
}));
});

View File

@ -1,62 +1,105 @@
<ng-container *ngIf="additionLinks">
<h4 class="margin-bottom-025">{{ 'ARTIFACT.ADDITIONS' | translate }}</h4>
<div class="min-15">
<clr-tabs>
<clr-tabs #additionsTab>
<clr-tab *ngIf="getVulnerability()">
<button clrTabLink id="vulnerability">
<button
clrTabLink
id="vulnerability"
(click)="actionTab('vulnerability')">
{{ 'REPOSITORY.VULNERABILITY' | translate }}
</button>
<clr-tab-content id="vulnerability-content" *clrIfActive>
<hbr-artifact-vulnerabilities
[artifact]="artifact"
[projectName]="projectName"
[projectId]="projectId"
[repoName]="repoName"
[digest]="digest"
[vulnerabilitiesLink]="
getVulnerability()
"></hbr-artifact-vulnerabilities>
</clr-tab-content>
<ng-template
[clrIfActive]="currentTabLinkId === 'vulnerability'">
<clr-tab-content id="vulnerability-content">
<hbr-artifact-vulnerabilities
[artifact]="artifact"
[projectName]="projectName"
[projectId]="projectId"
[repoName]="repoName"
[digest]="digest"
[vulnerabilitiesLink]="
getVulnerability()
"></hbr-artifact-vulnerabilities>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="getSbom()">
<button clrTabLink id="sbom" (click)="actionTab('sbom')">
{{ 'REPOSITORY.SBOM' | translate }}
</button>
<ng-template [clrIfActive]="currentTabLinkId === 'sbom'">
<clr-tab-content id="sbom-content">
<hbr-artifact-sbom
[artifact]="artifact"
[projectName]="projectName"
[projectId]="projectId"
[repoName]="repoName"
[sbomDigest]="sbomDigest"></hbr-artifact-sbom>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="getBuildHistory()">
<button clrTabLink id="build-history">
<button
clrTabLink
id="build-history"
(click)="actionTab('build-history')">
{{ 'REPOSITORY.BUILD_HISTORY' | translate }}
</button>
<clr-tab-content *clrIfActive>
<hbr-artifact-build-history
[buildHistoryLink]="
getBuildHistory()
"></hbr-artifact-build-history>
</clr-tab-content>
<ng-template
[clrIfActive]="currentTabLinkId === 'build-history'">
<clr-tab-content>
<hbr-artifact-build-history
[buildHistoryLink]="
getBuildHistory()
"></hbr-artifact-build-history>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="getSummary()">
<button clrTabLink id="summary-link">
<button
clrTabLink
id="summary-link"
(click)="actionTab('summary-link')">
{{ 'ARTIFACT.SUMMARY' | translate }}
</button>
<clr-tab-content id="summary-content" *clrIfActive>
<hbr-artifact-summary
[summaryLink]="getSummary()"></hbr-artifact-summary>
</clr-tab-content>
<ng-template
[clrIfActive]="currentTabLinkId === 'summary-link'">
<clr-tab-content id="summary-content">
<hbr-artifact-summary
[summaryLink]="getSummary()"></hbr-artifact-summary>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="getDependencies()">
<button clrTabLink id="depend-link">
<button
clrTabLink
id="depend-link"
(click)="actionTab('depend-link')">
{{ 'ARTIFACT.DEPENDENCIES' | translate }}
</button>
<clr-tab-content id="depend-content" *clrIfActive>
<hbr-artifact-dependencies
[dependenciesLink]="
getDependencies()
"></hbr-artifact-dependencies>
</clr-tab-content>
<ng-template [clrIfActive]="currentTabLinkId === 'depend-link'">
<clr-tab-content id="depend-content">
<hbr-artifact-dependencies
[dependenciesLink]="
getDependencies()
"></hbr-artifact-dependencies>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="getValues()">
<button clrTabLink id="value-link">
<button
clrTabLink
id="value-link"
(click)="actionTab('value-link')">
{{ 'ARTIFACT.VALUES' | translate }}
</button>
<clr-tab-content id="value-content" *clrIfActive>
<hbr-artifact-values
[valuesLink]="getValues()"></hbr-artifact-values>
</clr-tab-content>
<ng-template [clrIfActive]="currentTabLinkId === 'value-link'">
<clr-tab-content id="value-content">
<hbr-artifact-values
[valuesLink]="getValues()"></hbr-artifact-values>
</clr-tab-content>
</ng-template>
</clr-tab>
</clr-tabs>
</div>

View File

@ -1,15 +1,23 @@
import { Component, Input } from '@angular/core';
import {
AfterViewChecked,
ChangeDetectorRef,
Component,
Input,
OnInit,
ViewChild,
} from '@angular/core';
import { ADDITIONS } from './models';
import { AdditionLinks } from '../../../../../../../ng-swagger-gen/models/addition-links';
import { AdditionLink } from '../../../../../../../ng-swagger-gen/models/addition-link';
import { Artifact } from '../../../../../../../ng-swagger-gen/models/artifact';
import { ClrTabs } from '@clr/angular';
@Component({
selector: 'artifact-additions',
templateUrl: './artifact-additions.component.html',
styleUrls: ['./artifact-additions.component.scss'],
})
export class ArtifactAdditionsComponent {
export class ArtifactAdditionsComponent implements AfterViewChecked, OnInit {
@Input() artifact: Artifact;
@Input() additionLinks: AdditionLinks;
@Input() projectName: string;
@ -19,7 +27,28 @@ export class ArtifactAdditionsComponent {
repoName: string;
@Input()
digest: string;
constructor() {}
@Input()
sbomDigest: string;
@Input()
tab: string;
@Input() currentTabLinkId: string = 'vulnerability';
activeTab: string = null;
@ViewChild('additionsTab') tabs: ClrTabs;
constructor(private ref: ChangeDetectorRef) {}
ngOnInit(): void {
this.activeTab = this.tab;
}
ngAfterViewChecked() {
if (this.activeTab) {
this.currentTabLinkId = this.activeTab;
this.activeTab = null;
}
this.ref.detectChanges();
}
getVulnerability(): AdditionLink {
if (
@ -30,6 +59,12 @@ export class ArtifactAdditionsComponent {
}
return null;
}
getSbom(): AdditionLink {
if (this.additionLinks && this.additionLinks[ADDITIONS.SBOMS]) {
return this.additionLinks[ADDITIONS.SBOMS];
}
return {};
}
getBuildHistory(): AdditionLink {
if (this.additionLinks && this.additionLinks[ADDITIONS.BUILD_HISTORY]) {
return this.additionLinks[ADDITIONS.BUILD_HISTORY];
@ -54,4 +89,8 @@ export class ArtifactAdditionsComponent {
}
return null;
}
actionTab(tab: string): void {
this.currentTabLinkId = tab;
}
}

View File

@ -0,0 +1,92 @@
<div class="row result-row">
<div>
<div class="row flex-items-xs-right rightPos">
<div class="flex-xs-middle option-right">
<span class="refresh-btn" (click)="refresh()"
><clr-icon shape="refresh"></clr-icon
></span>
</div>
</div>
</div>
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<clr-datagrid [clrDgLoading]="loading" (clrDgRefresh)="load($event)">
<clr-dg-action-bar>
<div class="clr-row center">
<div class="ml-05">
<button
id="sbom-btn"
(click)="downloadSbom()"
type="button"
class="btn btn-secondary"
[clrLoading]="downloadSbomBtnState"
[disabled]="!canDownloadSbom()">
<clr-icon
shape="download"
size="16"
*ngIf="!isRunningState()"></clr-icon
>&nbsp;
<span *ngIf="!isRunningState()">{{
'SBOM.DOWNLOAD' | translate
}}</span>
</button>
</div>
</div>
</clr-dg-action-bar>
<clr-dg-column [clrDgField]="'package'" class="package-medium">{{
'SBOM.GRID.COLUMN_PACKAGE' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgField]="'version'" class="version-medium">{{
'SBOM.GRID.COLUMN_VERSION' | translate
}}</clr-dg-column>
<clr-dg-column>{{
'SBOM.GRID.COLUMN_LICENSE' | translate
}}</clr-dg-column>
<clr-dg-placeholder>
<span *ngIf="hasGeneratedSbom(); else elseBlock">{{
'SBOM.STATE.OTHER_STATUS' | translate
}}</span>
<ng-template #elseBlock>
{{ 'SBOM.CHART.TOOLTIPS_TITLE_ZERO' | translate }}
</ng-template>
</clr-dg-placeholder>
<clr-dg-row *clrDgItems="let res of artifactSbomPackages()">
<clr-dg-cell class="package-medium">{{
res.name ?? ''
}}</clr-dg-cell>
<clr-dg-cell class="version-medium">{{
res.versionInfo ?? ''
}}</clr-dg-cell>
<clr-dg-cell>{{ res.licenseConcluded ?? '' }}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<div class="report">
<i *ngIf="artifact?.sbom_overview">{{
'SBOM.REPORTED_BY'
| translate
: {
scanner: getScannerInfo(
artifact?.sbom_overview?.scanner
)
}
}}</i>
</div>
<clr-dg-pagination
#pagination
[clrDgPageSize]="pageSize"
[clrDgTotalItems]="artifactSbomPackages().length">
<clr-dg-page-size [clrPageSizeOptions]="[15, 25, 50]">{{
'PAGINATION.PAGE_SIZE' | translate
}}</clr-dg-page-size>
<span *ngIf="artifactSbomPackages().length"
>{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }}
{{ 'SBOM.GRID.FOOT_OF' | translate }}</span
>
{{ artifactSbomPackages().length }}
{{ 'SBOM.GRID.FOOT_ITEMS' | translate }}
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>

View File

@ -0,0 +1,74 @@
.result-row {
position: relative;
}
/* stylelint-disable */
.rightPos{
position: absolute;
z-index: 100;
right: 0;
margin-top: 1.25rem;
}
.option-right {
padding-right: 16px;
margin-top: 5px;
}
.center {
align-items: center;
}
.label-critical {
background:#ff4d2e;
color:#000;
}
.label-danger {
background:#ff8f3d!important;
color:#000!important;
}
.label-medium {
background-color: #ffce66;
color:#000;
}
.label-low {
background: #fff1ad;
color:#000;
}
.label-none {
background-color: #2ec0ff;
color:#000;
}
.no-border {
border: none;
}
.ml-05 {
margin-left: 0.5rem;
}
.report {
text-align: left;
}
.mt-5px {
margin-top: 5px;
}
.label {
min-width: 3rem;
}
.package-medium {
max-width: 25rem;
text-overflow: ellipsis;
}
.version-medium {
min-width: 22rem;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,194 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ArtifactSbomComponent } from './artifact-sbom.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ClarityModule } from '@clr/angular';
import { of } from 'rxjs';
import {
TranslateFakeLoader,
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { UserPermissionService } from '../../../../../../shared/services';
import { AdditionLink } from '../../../../../../../../ng-swagger-gen/models/addition-link';
import { ErrorHandler } from '../../../../../../shared/units/error-handler';
import { SessionService } from '../../../../../../shared/services/session.service';
import { SessionUser } from '../../../../../../shared/entities/session-user';
import { AppConfigService } from 'src/app/services/app-config.service';
import { ArtifactSbomPackageItem } from '../../artifact';
import { ArtifactService } from 'ng-swagger-gen/services';
import { ArtifactListPageService } from '../../artifact-list-page/artifact-list-page.service';
describe('ArtifactSbomComponent', () => {
let component: ArtifactSbomComponent;
let fixture: ComponentFixture<ArtifactSbomComponent>;
const artifactSbomPackages: ArtifactSbomPackageItem[] = [
{
name: 'alpine-baselayout',
SPDXID: 'SPDXRef-Package-5b53573c19a59415',
versionInfo: '3.2.0-r18',
supplier: 'NOASSERTION',
downloadLocation: 'NONE',
checksums: [
{
algorithm: 'SHA1',
checksumValue: '132992eab020986b3b5d886a77212889680467a0',
},
],
sourceInfo: 'built package from: alpine-baselayout 3.2.0-r18',
licenseConcluded: 'GPL-2.0-only',
licenseDeclared: 'GPL-2.0-only',
copyrightText: '',
externalRefs: [
{
referenceCategory: 'PACKAGE-MANAGER',
referenceType: 'purl',
referenceLocator:
'pkg:apk/alpine/alpine-baselayout@3.2.0-r18?arch=x86_64\u0026distro=3.15.5',
},
],
attributionTexts: [
'PkgID: alpine-baselayout@3.2.0-r18',
'LayerDiffID: sha256:ad543cd673bd9de2bac48599da992506dcc37a183179302ea934853aaa92cb84',
],
primaryPackagePurpose: 'LIBRARY',
},
{
name: 'alpine-keys',
SPDXID: 'SPDXRef-Package-7e5952f7a76e9643',
versionInfo: '2.4-r1',
supplier: 'NOASSERTION',
downloadLocation: 'NONE',
checksums: [
{
algorithm: 'SHA1',
checksumValue: '903176b2d2a8ddefd1ba6940f19ad17c2c1d4aff',
},
],
sourceInfo: 'built package from: alpine-keys 2.4-r1',
licenseConcluded: 'MIT',
licenseDeclared: 'MIT',
copyrightText: '',
externalRefs: [
{
referenceCategory: 'PACKAGE-MANAGER',
referenceType: 'purl',
referenceLocator:
'pkg:apk/alpine/alpine-keys@2.4-r1?arch=x86_64\u0026distro=3.15.5',
},
],
attributionTexts: [
'PkgID: alpine-keys@2.4-r1',
'LayerDiffID: sha256:ad543cd673bd9de2bac48599da992506dcc37a183179302ea934853aaa92cb84',
],
primaryPackagePurpose: 'LIBRARY',
},
];
const artifactSbomJson = {
spdxVersion: 'SPDX-2.3',
dataLicense: 'CC0-1.0',
SPDXID: 'SPDXRef-DOCUMENT',
name: 'alpine:3.15.5',
documentNamespace:
'http://aquasecurity.github.io/trivy/container_image/alpine:3.15.5-7ead854c-7340-44c9-bbbf-5403c21cc9b6',
creationInfo: {
licenseListVersion: '',
creators: ['Organization: aquasecurity', 'Tool: trivy-0.47.0'],
created: '2023-11-29T07:06:22Z',
},
packages: artifactSbomPackages,
};
const fakedArtifactService = {
getAddition() {
return of(JSON.stringify(artifactSbomJson));
},
};
const fakedUserPermissionService = {
hasProjectPermissions() {
return of(true);
},
};
const fakedAppConfigService = {
getConfig() {
return of({ sbom_enabled: true });
},
};
const mockedUser: SessionUser = {
user_id: 1,
username: 'admin',
email: 'harbor@vmware.com',
realname: 'admin',
has_admin_role: true,
comment: 'no comment',
};
const fakedSessionService = {
getCurrentUser() {
return mockedUser;
},
};
const mockedSbomDigest =
'sha256:51a41cec9de9d62ee60e206f5a8a615a028a65653e45539990867417cb486285';
const mockedArtifactListPageService = {
hasScannerSupportSBOM(): boolean {
return true;
},
init() {},
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
BrowserAnimationsModule,
ClarityModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateFakeLoader,
},
}),
],
declarations: [ArtifactSbomComponent],
providers: [
ErrorHandler,
{ provide: AppConfigService, useValue: fakedAppConfigService },
{ provide: ArtifactService, useValue: fakedArtifactService },
{
provide: UserPermissionService,
useValue: fakedUserPermissionService,
},
{ provide: SessionService, useValue: fakedSessionService },
{
provide: ArtifactListPageService,
useValue: mockedArtifactListPageService,
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ArtifactSbomComponent);
component = fixture.componentInstance;
component.hasSbomPermission = true;
component.hasScannerSupportSBOM = true;
component.sbomDigest = mockedSbomDigest;
component.ngOnInit();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should get sbom list and render', async () => {
fixture.detectChanges();
await fixture.whenStable();
const rows = fixture.nativeElement.getElementsByTagName('clr-dg-row');
expect(rows.length).toEqual(2);
});
it('download button should show the right text', async () => {
fixture.autoDetectChanges(true);
const scanBtn: HTMLButtonElement =
fixture.nativeElement.querySelector('#sbom-btn');
expect(scanBtn.innerText).toContain('SBOM.DOWNLOAD');
});
});

View File

@ -0,0 +1,248 @@
import {
AfterViewInit,
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import { ClrDatagridStateInterface, ClrLoadingState } from '@clr/angular';
import { finalize } from 'rxjs/operators';
import { AdditionLink } from '../../../../../../../../ng-swagger-gen/models/addition-link';
import {
ScannerVo,
UserPermissionService,
USERSTATICPERMISSION,
} from '../../../../../../shared/services';
import { ErrorHandler } from '../../../../../../shared/units/error-handler';
import {
dbEncodeURIComponent,
downloadJson,
getPageSizeFromLocalStorage,
PageSizeMapKeys,
SBOM_SCAN_STATUS,
setPageSizeToLocalStorage,
} from '../../../../../../shared/units/utils';
import { Subscription } from 'rxjs';
import { Artifact } from '../../../../../../../../ng-swagger-gen/models/artifact';
import { SessionService } from '../../../../../../shared/services/session.service';
import {
EventService,
HarborEvent,
} from '../../../../../../services/event-service/event.service';
import { severityText } from '../../../../../left-side-nav/interrogation-services/vulnerability-database/security-hub.interface';
import { AppConfigService } from 'src/app/services/app-config.service';
import {
ArtifactSbom,
ArtifactSbomPackageItem,
getArtifactSbom,
} from '../../artifact';
import { ArtifactService } from 'ng-swagger-gen/services';
import { ScanTypes } from 'src/app/shared/entities/shared.const';
import { ArtifactListPageService } from '../../artifact-list-page/artifact-list-page.service';
@Component({
selector: 'hbr-artifact-sbom',
templateUrl: './artifact-sbom.component.html',
styleUrls: ['./artifact-sbom.component.scss'],
})
export class ArtifactSbomComponent implements OnInit, OnDestroy {
@Input()
projectName: string;
@Input()
projectId: number;
@Input()
repoName: string;
@Input()
sbomDigest: string;
@Input() artifact: Artifact;
artifactSbom: ArtifactSbom;
loading: boolean = false;
hasScannerSupportSBOM: boolean = false;
downloadSbomBtnState: ClrLoadingState = ClrLoadingState.DEFAULT;
hasSbomPermission: boolean = false;
hasShowLoading: boolean = false;
sub: Subscription;
hasViewInitWithDelay: boolean = false;
pageSize: number = getPageSizeFromLocalStorage(
PageSizeMapKeys.ARTIFACT_SBOM_COMPONENT,
25
);
readonly severityText = severityText;
constructor(
private errorHandler: ErrorHandler,
private appConfigService: AppConfigService,
private artifactService: ArtifactService,
private artifactListPageService: ArtifactListPageService,
private userPermissionService: UserPermissionService,
private eventService: EventService,
private session: SessionService
) {}
ngOnInit() {
this.artifactListPageService.init(this.projectId);
this.getSbom();
this.getSbomPermission();
if (!this.sub) {
this.sub = this.eventService.subscribe(
HarborEvent.UPDATE_SBOM_INFO,
(artifact: Artifact) => {
if (artifact?.digest === this.artifact?.digest) {
if (artifact.sbom_overview) {
const sbomDigest = Object.values(
artifact.sbom_overview
)?.[0]?.sbom_digest;
if (sbomDigest) {
this.sbomDigest = sbomDigest;
}
}
this.getSbom();
}
}
);
}
setTimeout(() => {
this.hasViewInitWithDelay = true;
}, 0);
}
ngOnDestroy() {
if (this.sub) {
this.sub.unsubscribe();
this.sub = null;
}
}
getSbom() {
if (this.sbomDigest) {
if (!this.hasShowLoading) {
this.loading = true;
this.hasShowLoading = true;
}
const sbomAdditionParams = <ArtifactService.GetAdditionParams>{
repositoryName: dbEncodeURIComponent(this.repoName),
reference: this.sbomDigest,
projectName: this.projectName,
addition: ScanTypes.SBOM,
};
this.artifactService
.getAddition(sbomAdditionParams)
.pipe(
finalize(() => {
this.loading = false;
this.hasShowLoading = false;
})
)
.subscribe(
res => {
if (res) {
this.artifactSbom = getArtifactSbom(
JSON.parse(res)
);
} else {
this.loading = false;
this.hasShowLoading = false;
}
},
error => {
this.errorHandler.error(error);
}
);
}
}
getSbomPermission(): void {
const permissions = [
{
resource: USERSTATICPERMISSION.REPOSITORY_TAG_SBOM_JOB.KEY,
action: USERSTATICPERMISSION.REPOSITORY_TAG_SBOM_JOB.VALUE.READ,
},
];
this.userPermissionService
.hasProjectPermissions(this.projectId, permissions)
.subscribe(
(results: Array<boolean>) => {
this.hasSbomPermission = results[0];
// only has label permission
},
error => this.errorHandler.error(error)
);
}
refresh(): void {
this.getSbom();
}
hasGeneratedSbom(): boolean {
return this.hasViewInitWithDelay;
}
isSystemAdmin(): boolean {
const account = this.session.getCurrentUser();
return account && account.has_admin_role;
}
getScannerInfo(scanner: ScannerVo): string {
if (scanner) {
if (scanner.name && scanner.version) {
return `${scanner.name}@${scanner.version}`;
}
if (scanner.name && !scanner.version) {
return `${scanner.name}`;
}
}
return '';
}
isRunningState(): boolean {
return (
this.hasViewInitWithDelay &&
this.artifact.sbom_overview &&
(this.artifact.sbom_overview.scan_status ===
SBOM_SCAN_STATUS.PENDING ||
this.artifact.sbom_overview.scan_status ===
SBOM_SCAN_STATUS.RUNNING)
);
}
downloadSbom() {
this.downloadSbomBtnState = ClrLoadingState.LOADING;
if (
this.artifact?.sbom_overview?.scan_status ===
SBOM_SCAN_STATUS.SUCCESS
) {
downloadJson(
this.artifactSbom.sbomJsonRaw,
`${this.artifactSbom.sbomName}.json`
);
}
this.downloadSbomBtnState = ClrLoadingState.DEFAULT;
}
canDownloadSbom(): boolean {
this.hasScannerSupportSBOM =
this.artifactListPageService.hasScannerSupportSBOM();
return (
this.hasScannerSupportSBOM &&
//this.hasSbomPermission &&
this.sbomDigest &&
this.downloadSbomBtnState !== ClrLoadingState.LOADING &&
this.artifactSbom !== undefined
);
}
artifactSbomPackages(): ArtifactSbomPackageItem[] {
return this.artifactSbom?.sbomPackage?.packages ?? [];
}
load(state: ClrDatagridStateInterface) {
if (state?.page?.size) {
setPageSizeToLocalStorage(
PageSizeMapKeys.ARTIFACT_SBOM_COMPONENT,
state.page.size
);
}
}
}

View File

@ -6,6 +6,7 @@ import {
USERSTATICPERMISSION,
} from '../../../../../shared/services';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { Scanner } from '../../../../left-side-nav/interrogation-services/scanner/scanner';
@Injectable()
export class ArtifactListPageService {
@ -19,6 +20,7 @@ export class ArtifactListPageService {
private _hasDeleteImagePermission: boolean = false;
private _hasScanImagePermission: boolean = false;
private _hasSbomPermission: boolean = false;
private _scanner: Scanner = undefined;
constructor(
private scanningService: ScanningResultService,
@ -26,6 +28,10 @@ export class ArtifactListPageService {
private errorHandlerService: ErrorHandler
) {}
getProjectScanner(): Scanner {
return this._scanner;
}
getScanBtnState(): ClrLoadingState {
return this._scanBtnState;
}
@ -103,29 +109,28 @@ export class ArtifactListPageService {
this._sbomBtnState = ClrLoadingState.LOADING;
this.scanningService.getProjectScanner(projectId).subscribe(
response => {
if (
response &&
'{}' !== JSON.stringify(response) &&
!response.disabled &&
response.health === 'healthy'
) {
this.updateStates(
true,
ClrLoadingState.SUCCESS,
ClrLoadingState.SUCCESS
);
if (response?.capabilities) {
this.updateCapabilities(response?.capabilities);
if (response && '{}' !== JSON.stringify(response)) {
this._scanner = response;
if (!response.disabled && response.health === 'healthy') {
this.updateStates(
true,
ClrLoadingState.SUCCESS,
ClrLoadingState.SUCCESS
);
if (response?.capabilities) {
this.updateCapabilities(response?.capabilities);
}
} else {
this.updateStates(
false,
ClrLoadingState.ERROR,
ClrLoadingState.ERROR
);
}
} else {
this.updateStates(
false,
ClrLoadingState.ERROR,
ClrLoadingState.ERROR
);
}
},
error => {
this._scanner = null;
this.updateStates(
false,
ClrLoadingState.ERROR,

View File

@ -24,6 +24,7 @@
canScanNow() &&
selectedRowHasVul() &&
hasEnabledScanner &&
hasScannerSupportVulnerability &&
hasScanImagePermission
)
"
@ -32,44 +33,26 @@
>&nbsp;
<span>{{ 'VULNERABILITY.SCAN_NOW' | translate }}</span>
</button>
<button
id="stop-scan"
[clrLoading]="stopBtnState"
type="button"
class="btn btn-secondary scan-btn"
[disabled]="!(canStopScan() && hasScanImagePermission)"
(click)="stopNow()">
<clr-icon shape="stop" size="16"></clr-icon>&nbsp;
<span>{{ 'VULNERABILITY.STOP_NOW' | translate }}</span>
</button>
<button
*ngIf="hasEnabledSbom()"
id="generate-sbom-btn"
[clrLoading]="generateSbomBtnState"
type="button"
class="btn btn-secondary"
[disabled]="true"
(click)="generateSbom()">
<clr-icon shape="file" size="16"></clr-icon>&nbsp;
<span>{{ 'SBOM.GENERATE' | translate }}</span>
</button>
<button
id="stop-sbom-btn"
*ngIf="hasEnabledSbom()"
[clrLoading]="stopBtnState"
type="button"
class="btn btn-secondary scan-btn"
[disabled]="
!(
canStopSbom() &&
canGenerateSbomNow() &&
selectedRowHasSbom() &&
hasEnabledScanner &&
hasSbomPermission &&
hasEnabledScanner
hasScannerSupportSBOM
)
"
(click)="stopSbom()">
<clr-icon shape="stop" size="16"></clr-icon>&nbsp;
<span>{{ 'SBOM.STOP' | translate }}</span>
(click)="generateSbom()">
<clr-icon shape="sbom" size="16"></clr-icon>&nbsp;
<span>{{ 'SBOM.GENERATE' | translate }}</span>
</button>
<clr-dropdown class="btn btn-link" *ngIf="!depth">
<span
clrDropdownTrigger
@ -82,6 +65,45 @@
class="action-dropdown"
clrPosition="bottom-left"
*clrIfOpen>
<div
class="dropdown-divider"
role="separator"
aria-hidden="true"></div>
<button
clrDropdownItem
id="stop-scan"
[clrLoading]="stopBtnState"
type="button"
class="btn btn-secondary scan-btn action-dropdown-item"
[disabled]="
!(canStopScan() && hasScanImagePermission)
"
(click)="stopNow()">
<span>{{
'VULNERABILITY.STOP_NOW' | translate
}}</span>
</button>
<button
clrDropdownItem
id="stop-sbom-btn"
*ngIf="hasEnabledSbom()"
[clrLoading]="stopBtnState"
type="button"
class="btn btn-secondary scan-btn action-dropdown-item"
[disabled]="
!(
canStopSbom() &&
hasSbomPermission &&
hasEnabledScanner
)
"
(click)="stopSbom()">
<span>{{ 'SBOM.STOP' | translate }}</span>
</button>
<div
class="dropdown-divider"
role="separator"
aria-hidden="true"></div>
<div
id="artifact-list-copy-digest"
class="action-dropdown-item no-border"
@ -93,6 +115,10 @@
(click)="showDigestId()">
{{ 'REPOSITORY.COPY_DIGEST_ID' | translate }}
</div>
<div
class="dropdown-divider"
role="separator"
aria-hidden="true"></div>
<clr-dropdown>
<button
id="artifact-list-add-labels"
@ -106,6 +132,10 @@
">
{{ 'REPOSITORY.ADD_LABELS' | translate }}
</button>
<div
class="dropdown-divider"
role="separator"
aria-hidden="true"></div>
<clr-dropdown-menu
[hidden]="!selectedRow.length">
<div class="filter-grid">
@ -432,7 +462,7 @@
(submitStopFinish)="submitSbomStopFinish($event)"
(scanFinished)="sbomFinished($event)"
*ngIf="hasScannerSupportSBOM"
[inputScanner]="artifact?.sbom_overview?.scanner"
[inputScanner]="projectScanner"
(submitFinish)="submitSbomFinish($event)"
[projectName]="projectName"
[projectId]="projectId"

View File

@ -27,11 +27,17 @@ import { ArtifactModule } from '../../../artifact.module';
import {
SBOM_SCAN_STATUS,
VULNERABILITY_SCAN_STATUS,
} from 'src/app/shared/units/utils';
} from '../../../../../../../shared/units/utils';
import { Scanner } from '../../../../../../left-side-nav/interrogation-services/scanner/scanner';
describe('ArtifactListTabComponent', () => {
let comp: ArtifactListTabComponent;
let fixture: ComponentFixture<ArtifactListTabComponent>;
const mockScanner = {
name: 'Trivy',
vendor: 'vm',
version: 'v1.2',
};
const mockActivatedRoute = {
snapshot: {
params: {
@ -274,6 +280,9 @@ describe('ArtifactListTabComponent', () => {
hasScanImagePermission(): boolean {
return true;
},
getProjectScanner(): Scanner {
return mockScanner;
},
init() {},
};
beforeEach(async () => {
@ -384,7 +393,7 @@ describe('ArtifactListTabComponent', () => {
fixture.nativeElement.querySelector('#generate-sbom-btn');
fixture.detectChanges();
await fixture.whenStable();
expect(generatedButton.disabled).toBeTruthy();
expect(generatedButton.disabled).toBeFalsy();
});
it('Stop SBOM button should be disabled', async () => {
await fixture.whenStable();

View File

@ -84,6 +84,7 @@ import { Accessory } from 'ng-swagger-gen/models/accessory';
import { Tag } from '../../../../../../../../../ng-swagger-gen/models/tag';
import { CopyArtifactComponent } from './copy-artifact/copy-artifact.component';
import { CopyDigestComponent } from './copy-digest/copy-digest.component';
import { Scanner } from '../../../../../../left-side-nav/interrogation-services/scanner/scanner';
export const AVAILABLE_TIME = '0001-01-01T00:00:00.000Z';
@ -160,6 +161,10 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
get generateSbomBtnState(): ClrLoadingState {
return this.artifactListPageService.getSbomBtnState();
}
get projectScanner(): Scanner {
return this.artifactListPageService.getProjectScanner();
}
onSendingScanCommand: boolean;
onSendingStopScanCommand: boolean = false;
onStopScanArtifactsLength: number = 0;
@ -190,7 +195,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
false,
false,
false,
true,
false,
true,
false,
false,
@ -269,6 +274,9 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
);
}
if (this.projectId) {
this.artifactListPageService.init(this.projectId);
}
}
ngOnDestroy() {
@ -360,7 +368,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
withImmutableStatus: true,
withLabel: true,
withScanOverview: true,
// withSbomOverview: true,
withSbomOverview: true,
withTag: false,
XAcceptVulnerabilities: DEFAULT_SUPPORTED_MIME_TYPES,
withAccessory: false,
@ -385,7 +393,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
withImmutableStatus: true,
withLabel: true,
withScanOverview: true,
// withSbomOverview: true,
withSbomOverview: true,
withTag: false,
XAcceptVulnerabilities:
DEFAULT_SUPPORTED_MIME_TYPES,
@ -435,6 +443,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
repositoryName: dbEncodeURIComponent(this.repoName),
withLabel: true,
withScanOverview: true,
withSbomOverview: true,
withTag: false,
sort: getSortingString(state),
XAcceptVulnerabilities: DEFAULT_SUPPORTED_MIME_TYPES,
@ -749,11 +758,15 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
if (this.activatedRoute.snapshot.queryParams[UN_LOGGED_PARAM] === YES) {
this.router.navigate(relativeRouterLink, {
relativeTo: this.activatedRoute,
queryParams: { [UN_LOGGED_PARAM]: YES },
queryParams: {
[UN_LOGGED_PARAM]: YES,
sbomDigest: artifact.sbomDigest ?? '',
},
});
} else {
this.router.navigate(relativeRouterLink, {
relativeTo: this.activatedRoute,
queryParams: { sbomDigest: artifact.sbomDigest ?? '' },
});
}
}
@ -800,6 +813,26 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return false;
}
// if has running job, return false
canGenerateSbomNow(): boolean {
if (!this.hasSbomPermission) {
return false;
}
if (this.onSendingSbomCommand) {
return false;
}
if (this.selectedRow && this.selectedRow.length) {
let flag: boolean = true;
this.selectedRow.forEach(item => {
const st: string = this.sbomStatus(item);
if (this.isRunningState(st)) {
flag = false;
}
});
return flag;
}
return false;
}
// Trigger scan
scanNow(): void {
if (!this.selectedRow.length) {
@ -816,6 +849,22 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
);
});
}
// Generate SBOM
generateSbom(): void {
if (!this.selectedRow.length) {
return;
}
this.sbomFinishedArtifactLength = 0;
this.onSbomArtifactsLength = this.selectedRow.length;
this.onSendingSbomCommand = true;
this.selectedRow.forEach((data: any) => {
let digest = data.digest;
this.eventService.publish(
HarborEvent.START_GENERATE_SBOM,
this.repoName + '/' + digest
);
});
}
selectedRowHasVul(): boolean {
return !!(
@ -941,7 +990,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
}
}
// when finished, remove it from selectedRow
sbomFinished(artifact: Artifact) {
this.scanFinished(artifact);
@ -1019,18 +1067,17 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
});
}
}
checkCosignAndSbomAsync(artifacts: ArtifactFront[]) {
if (artifacts) {
if (artifacts.length) {
artifacts.forEach(item => {
item.signed = CHECKING;
// const sbomOverview = item?.sbom_overview;
// item.sbomDigest = sbomOverview?.sbom_digest;
// let queryTypes = `${AccessoryType.COSIGN} ${AccessoryType.NOTATION}`;
// if (!item.sbomDigest) {
// queryTypes = `${queryTypes} ${AccessoryType.SBOM}`;
// }
const sbomOverview = item?.sbom_overview;
item.sbomDigest = sbomOverview?.sbom_digest;
let queryTypes = `${AccessoryType.COSIGN} ${AccessoryType.NOTATION}`;
if (!item.sbomDigest) {
queryTypes = `${queryTypes} ${AccessoryType.SBOM}`;
}
this.newArtifactService
.listAccessories({
projectName: this.projectName,
@ -1038,16 +1085,21 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
reference: item.digest,
page: 1,
pageSize: ACCESSORY_PAGE_SIZE,
q: encodeURIComponent(
`type={${AccessoryType.COSIGN} ${AccessoryType.NOTATION}}`
),
q: encodeURIComponent(`type={${queryTypes}}`),
})
.subscribe({
next: res => {
if (res?.length) {
item.signed = TRUE;
} else {
item.signed = FALSE;
item.signed = res?.filter(
item => item.type !== AccessoryType.SBOM
)?.length
? TRUE
: FALSE;
if (!item.sbomDigest) {
item.sbomDigest =
res?.filter(
item =>
item.type === AccessoryType.SBOM
)?.[0]?.digest ?? null;
}
},
error: err => {
@ -1075,7 +1127,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return false;
}
// return true if all selected rows are in "running" state
canStopSbom(): boolean {
if (this.onSendingStopSbomCommand) {
@ -1142,6 +1193,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return null;
}
deleteAccessory(a: Accessory) {
let titleKey: string,
summaryKey: string,

View File

@ -59,6 +59,8 @@
[projectId]="projectId"
[repoName]="repositoryName"
[digest]="artifactDigest"
[sbomDigest]="sbomDigest"
[tab]="activeTab"
[additionLinks]="artifact?.addition_links"></artifact-additions>
</ng-container>
<div *ngIf="loading" class="clr-row mt-3 center">

View File

@ -30,6 +30,8 @@ describe('ArtifactSummaryComponent', () => {
return undefined;
},
};
const mockedSbomDigest =
'sha256:51a41cec9de9d62ee60e206f5a8a615a028a65653e45539990867417cb486285';
let component: ArtifactSummaryComponent;
let fixture: ComponentFixture<ArtifactSummaryComponent>;
const mockActivatedRoute = {
@ -42,6 +44,9 @@ describe('ArtifactSummaryComponent', () => {
return of(null);
},
},
queryParams: {
sbomDigest: mockedSbomDigest,
},
parent: {
params: {
id: 1,
@ -89,6 +94,7 @@ describe('ArtifactSummaryComponent', () => {
component = fixture.componentInstance;
component.repositoryName = 'demo';
component.artifactDigest = 'sha: acf4234f';
component.sbomDigest = mockedSbomDigest;
fixture.detectChanges();
});

View File

@ -1,10 +1,7 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Artifact } from '../../../../../../ng-swagger-gen/models/artifact';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import { Label } from '../../../../../../ng-swagger-gen/models/label';
import { ProjectService } from '../../../../shared/services';
import { ActivatedRoute, Router } from '@angular/router';
import { AppConfigService } from '../../../../services/app-config.service';
import { Project } from '../../project';
import { artifactDefault } from './artifact';
import { SafeUrl } from '@angular/platform-browser';
@ -24,6 +21,8 @@ import {
export class ArtifactSummaryComponent implements OnInit {
tagId: string;
artifactDigest: string;
sbomDigest?: string;
activeTab?: string;
repositoryName: string;
projectId: string | number;
referArtifactNameArray: string[] = [];
@ -37,10 +36,7 @@ export class ArtifactSummaryComponent implements OnInit {
loading: boolean = false;
constructor(
private projectService: ProjectService,
private errorHandler: ErrorHandler,
private route: ActivatedRoute,
private appConfigService: AppConfigService,
private router: Router,
private frontEndArtifactService: ArtifactService,
private event: EventService
@ -100,6 +96,8 @@ export class ArtifactSummaryComponent implements OnInit {
this.repositoryName = this.route.snapshot.params['repo'];
this.artifactDigest = this.route.snapshot.params['digest'];
this.projectId = this.route.snapshot.parent.params['id'];
this.sbomDigest = this.route.snapshot.queryParams['sbomDigest'];
this.activeTab = this.route.snapshot.queryParams['tab'];
if (this.repositoryName && this.artifactDigest) {
const resolverData = this.route.snapshot.data;
if (resolverData) {

View File

@ -12,6 +12,7 @@ import { SummaryComponent } from './artifact-additions/summary/summary.component
import { DependenciesComponent } from './artifact-additions/dependencies/dependencies.component';
import { BuildHistoryComponent } from './artifact-additions/build-history/build-history.component';
import { ArtifactVulnerabilitiesComponent } from './artifact-additions/artifact-vulnerabilities/artifact-vulnerabilities.component';
import { ArtifactSbomComponent } from './artifact-additions/artifact-sbom/artifact-sbom.component';
import { ArtifactDefaultService, ArtifactService } from './artifact.service';
import { ArtifactDetailRoutingResolverService } from '../../../../services/routing-resolvers/artifact-detail-routing-resolver.service';
import { ResultBarChartComponent } from './vulnerability-scanning/result-bar-chart.component';
@ -80,6 +81,7 @@ const routes: Routes = [
SummaryComponent,
DependenciesComponent,
BuildHistoryComponent,
ArtifactSbomComponent,
ArtifactVulnerabilitiesComponent,
ResultBarChartComponent,
ResultSbomComponent,

View File

@ -10,6 +10,7 @@ export interface ArtifactFront extends Artifact {
annotationsArray?: Array<{ [key: string]: any }>;
tagNumber?: number;
signed?: string;
sbomDigest?: string;
accessoryNumber?: number;
}
@ -75,6 +76,7 @@ export enum AccessoryType {
COSIGN = 'signature.cosign',
NOTATION = 'signature.notation',
NYDUS = 'accelerator.nydus',
SBOM = 'harbor.sbom',
}
export enum ArtifactType {
@ -166,3 +168,196 @@ export enum ClientNames {
CHART = 'Helm',
CNAB = 'CNAB',
}
export enum ArtifactSbomType {
SPDX = 'SPDX',
}
export interface ArtifactSbomPackageItem {
name?: string;
versionInfo?: string;
licenseConcluded?: string;
[key: string]: Object;
}
export interface ArtifactSbomPackage {
packages: ArtifactSbomPackageItem[];
}
export interface ArtifactSbom {
sbomType: ArtifactSbomType;
sbomVersion: string;
sbomName?: string;
sbomDataLicense?: string;
sbomId?: string;
sbomDocumentNamespace?: string;
sbomCreated?: string;
sbomPackage?: ArtifactSbomPackage;
sbomJsonRaw?: Object;
}
export const ArtifactSbomFieldMapper = {
sbomVersion: 'spdxVersion',
sbomName: 'name',
sbomDataLicense: 'dataLicense',
sbomId: 'SPDXID',
sbomDocumentNamespace: 'documentNamespace',
sbomCreated: 'creationInfo.created',
sbomPackage: {
packages: ['name', 'versionInfo', 'licenseConcluded'],
},
};
/**
* Identify the sbomJson contains the two main properties 'spdxVersion' and 'SPDXID'.
* @param sbomJson SBOM JSON report object.
* @returns true or false
* Return true when the sbomJson object contains the attribues 'spdxVersion' and 'SPDXID'.
* else return false.
*/
export function isSpdxSbom(sbomJson?: Object): boolean {
return Object.keys(sbomJson ?? {}).includes(ArtifactSbomFieldMapper.sbomId);
}
/**
* Update the value to the data object with the field path.
* @param fieldPath field class path eg {a: {b:'test'}}. field path for b is 'a.b'
* @param data The target object to receive the value.
* @param value The value will be set to the data object.
*/
export function updateObjectWithFieldPath(
fieldPath: string,
data: Object,
value: Object
) {
if (fieldPath && data) {
const fields = fieldPath?.split('.');
let tempData = data;
fields.forEach((field, index) => {
const properties = Object.getOwnPropertyNames(tempData);
if (field !== '__proto__' && field !== 'constructor') {
if (index === fields.length - 1) {
tempData[field] = value;
} else {
if (!properties.includes(field)) {
tempData[field] = {};
}
tempData = tempData[field];
}
}
});
}
}
/**
* Get value from data object with field path.
* @param fieldPath field class path eg {a: {b:'test'}}. field path for b is 'a.b'
* @param data The data source target object.
* @returns The value read from data object.
*/
export const getValueFromObjectWithFieldPath = (
fieldPath: string,
data: Object
) => {
let tempObject = data;
if (fieldPath && data) {
const fields = fieldPath?.split('.');
fields.forEach(field => {
if (tempObject) {
tempObject = tempObject[field] ?? null;
}
});
}
return tempObject;
};
/**
* Get value from source data object with field path.
* @param fieldPathObject The Object that contains the field paths.
* If we have an Object - {a: {b: 'test', c: [{ d: 2, e: 'v'}]}}.
* The field path for b is 'a.b'.
* The field path for c is {'a.c': ['d', 'e']'}.
* @param sourceData The data source target object.
* @returns the value by field class path.
*/
export function readDataFromArtifactSbomJson(
fieldPathObject: Object,
sourceData: Object
): Object {
let result = null;
if (sourceData) {
switch (typeof fieldPathObject) {
case 'string':
result = getValueFromObjectWithFieldPath(
fieldPathObject,
sourceData
);
break;
case 'object':
if (
Array.isArray(fieldPathObject) &&
Array.isArray(sourceData)
) {
result = sourceData.map(source => {
let arrayItem = {};
fieldPathObject.forEach(field => {
updateObjectWithFieldPath(
field,
arrayItem,
readDataFromArtifactSbomJson(field, source)
);
});
return arrayItem;
});
} else {
const fields = Object.getOwnPropertyNames(fieldPathObject);
result = result ? result : {};
fields.forEach(field => {
if (sourceData[field]) {
updateObjectWithFieldPath(
field,
result,
readDataFromArtifactSbomJson(
fieldPathObject[field],
sourceData[field]
)
);
}
});
}
break;
default:
break;
}
}
return result;
}
/**
* Convert SBOM Json report to ArtifactSbom
* @param sbomJson SBOM report in Json format
* @returns ArtifactSbom || null
*/
export function getArtifactSbom(sbomJson?: Object): ArtifactSbom {
if (sbomJson) {
if (isSpdxSbom(sbomJson)) {
const artifactSbom = <ArtifactSbom>{};
artifactSbom.sbomJsonRaw = sbomJson;
artifactSbom.sbomType = ArtifactSbomType.SPDX;
// only retrieve the fields defined in ArtifactSbomFieldMapper
const fields = Object.getOwnPropertyNames(ArtifactSbomFieldMapper);
fields.forEach(field => {
updateObjectWithFieldPath(
field,
artifactSbom,
readDataFromArtifactSbomJson(
ArtifactSbomFieldMapper[field],
sbomJson
)
);
});
return artifactSbom;
}
}
return null;
}

View File

@ -10,7 +10,6 @@ import { SbomTipHistogramComponent } from './sbom-tip-histogram/sbom-tip-histogr
import { SBOMOverview } from './sbom-overview';
import { of, timer } from 'rxjs';
import { ArtifactService, ScanService } from 'ng-swagger-gen/services';
import { Artifact } from 'ng-swagger-gen/models';
describe('ResultSbomComponent (inline template)', () => {
let component: ResultSbomComponent;
@ -21,23 +20,18 @@ describe('ResultSbomComponent (inline template)', () => {
};
const mockedSbomDigest =
'sha256:052240e8190b7057439d2bee1dffb9b37c8800e5c1af349f667635ae1debf8f3';
const mockScanner = {
name: 'Trivy',
vendor: 'vm',
version: 'v1.2',
};
const mockedSbomOverview = {
report_id: '12345',
scan_status: 'Error',
scanner: {
name: 'Trivy',
vendor: 'vm',
version: 'v1.2',
},
};
const mockedCloneSbomOverview = {
report_id: '12346',
scan_status: 'Pending',
scanner: {
name: 'Trivy',
vendor: 'vm',
version: 'v1.2',
},
};
const FakedScanService = {
scanArtifact: () => of({}),
@ -120,6 +114,7 @@ describe('ResultSbomComponent (inline template)', () => {
fixture = TestBed.createComponent(ResultSbomComponent);
component = fixture.componentInstance;
component.repoName = 'mockRepo';
component.inputScanner = mockScanner;
component.artifactDigest = mockedSbomDigest;
component.sbomDigest = mockedSbomDigest;
component.sbomOverview = mockData;
@ -180,9 +175,11 @@ describe('ResultSbomComponent (inline template)', () => {
});
it('Test ResultSbomComponent getScanner', () => {
fixture.detectChanges();
component.inputScanner = undefined;
expect(component.getScanner()).toBeUndefined();
component.inputScanner = mockScanner;
component.sbomOverview = mockedSbomOverview;
expect(component.getScanner()).toBe(mockedSbomOverview.scanner);
expect(component.getScanner()).toBe(mockScanner);
component.projectName = 'test';
component.repoName = 'ui';
component.artifactDigest = 'dg';
@ -239,7 +236,9 @@ describe('ResultSbomComponent (inline template)', () => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(component.stateCheckTimer).toBeUndefined();
expect(component.sbomOverview.scan_status).toBe(
SBOM_SCAN_STATUS.SUCCESS
);
});
});
});

View File

@ -8,7 +8,6 @@ import {
} from '@angular/core';
import { Subscription, timer } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { ScannerVo } from '../../../../../shared/services';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import {
clone,
@ -27,6 +26,7 @@ import { ScanService } from '../../../../../../../ng-swagger-gen/services/scan.s
import { ScanType } from 'ng-swagger-gen/models';
import { ScanTypes } from '../../../../../shared/entities/shared.const';
import { SBOMOverview } from './sbom-overview';
import { Scanner } from '../../../../left-side-nav/interrogation-services/scanner/scanner';
const STATE_CHECK_INTERVAL: number = 3000; // 3s
const RETRY_TIMES: number = 3;
@ -36,7 +36,7 @@ const RETRY_TIMES: number = 3;
styleUrls: ['./scanning.scss'],
})
export class ResultSbomComponent implements OnInit, OnDestroy {
@Input() inputScanner: ScannerVo;
@Input() inputScanner: Scanner;
@Input() repoName: string = '';
@Input() projectName: string = '';
@Input() projectId: string = '';
@ -176,9 +176,9 @@ export class ResultSbomComponent implements OnInit, OnDestroy {
projectName: this.projectName,
reference: this.artifactDigest,
repositoryName: dbEncodeURIComponent(this.repoName),
// scanType: <ScanType>{
// scan_type: ScanTypes.SBOM,
// },
scanType: <ScanType>{
scan_type: ScanTypes.SBOM,
},
})
.pipe(finalize(() => this.submitFinish.emit(false)))
.subscribe(
@ -219,15 +219,15 @@ export class ResultSbomComponent implements OnInit, OnDestroy {
projectName: this.projectName,
repositoryName: dbEncodeURIComponent(this.repoName),
reference: this.artifactDigest,
// withSbomOverview: true,
withSbomOverview: true,
XAcceptVulnerabilities: DEFAULT_SUPPORTED_MIME_TYPES,
})
.subscribe(
(artifact: Artifact) => {
// To keep the same summary reference, use value copy.
// if (artifact.sbom_overview) {
// this.copyValue(artifact.sbom_overview);
// }
if (artifact.sbom_overview) {
this.copyValue(artifact.sbom_overview);
}
if (!this.queued && !this.generating) {
// Scanning should be done
if (this.stateCheckTimer) {
@ -271,10 +271,7 @@ export class ResultSbomComponent implements OnInit, OnDestroy {
}/scan/${this.sbomOverview.report_id}/log`;
}
getScanner(): ScannerVo {
if (this.sbomOverview && this.sbomOverview.scanner) {
return this.sbomOverview.scanner;
}
getScanner(): Scanner {
return this.inputScanner;
}

View File

@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ScannerVo, SbomSummary } from '../../../../../../shared/services';
import { SbomSummary } from '../../../../../../shared/services';
import { SBOM_SCAN_STATUS } from '../../../../../../shared/units/utils';
import {
UN_LOGGED_PARAM,
@ -9,6 +9,7 @@ import {
} from '../../../../../../account/sign-in/sign-in.service';
import { HAS_STYLE_MODE, StyleMode } from '../../../../../../services/theme';
import { ScanTypes } from '../../../../../../shared/entities/shared.const';
import { Scanner } from '../../../../../left-side-nav/interrogation-services/scanner/scanner';
const MIN = 60;
const MIN_STR = 'min ';
@ -21,7 +22,7 @@ const SUCCESS_PCT: number = 100;
styleUrls: ['./sbom-tip-histogram.component.scss'],
})
export class SbomTipHistogramComponent {
@Input() scanner: ScannerVo;
@Input() scanner: Scanner;
@Input() sbomSummary: SbomSummary = {
scan_status: SBOM_SCAN_STATUS.NOT_GENERATED_SBOM,
};
@ -54,6 +55,7 @@ export class SbomTipHistogramComponent {
? `100%`
: '0%';
}
isLimitedSuccess(): boolean {
return (
this.sbomSummary && this.sbomSummary.complete_percent < SUCCESS_PCT

View File

@ -256,8 +256,8 @@ describe('ResultBarChartComponent (inline template)', () => {
});
it('Test ResultBarChartComponent getSummary', () => {
fixture.detectChanges();
// component.summary.scan_status = VULNERABILITY_SCAN_STATUS.SUCCESS;
component.getSummary();
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.SUCCESS;
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();

View File

@ -173,9 +173,9 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
projectName: this.projectName,
reference: this.artifactDigest,
repositoryName: dbEncodeURIComponent(this.repoName),
// scanType: <ScanType>{
// scan_type: ScanTypes.VULNERABILITY,
// },
scanType: <ScanType>{
scan_type: ScanTypes.VULNERABILITY,
},
})
.pipe(finalize(() => this.submitFinish.emit(false)))
.subscribe(

View File

@ -51,7 +51,7 @@ export class ArtifactDetailRoutingResolverService {
projectName: project.name,
withLabel: true,
withScanOverview: true,
// withSbomOverview: true,
withSbomOverview: true,
withTag: false,
withImmutableStatus: true,
}),

View File

@ -127,6 +127,23 @@ ClarityIcons.add({
21.18,0,0,0,4,21.42,21,21,0,0,0,7.71,33.58a1,1,0,0,0,.81.42h19a1,1,0,0,0,
.81-.42A21,21,0,0,0,32,21.42,21.18,21.18,0,0,0,29.1,10.49Z"/>
<rect class="cls-1" width="36" height="36"/></g></svg>`,
sbom: `
<?xml version='1.0' encoding='utf-8'?>
<!-- Generator: imaengine 6.0 -->
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0,0,512,512" style="enable-background:new 0 0 512 512;" version="1.1">
<defs/>
<g id="layer0">
<g transform="matrix(1 0 0 1 0 0)">
<path d="M341.333,213.333L362.666,213.333L362.666,117.333C362.619,116.239 362.399,115.159 362.015,114.133C361.93,113.866 361.951,113.557 361.844,113.29C361.285,111.937 360.454,110.713 359.401,109.695L252.885,3.136C250.88,1.135 248.166,0.0079999 245.333,0L10.667,0C4.776,0 0,4.776 0,10.667L0,458.667C0,464.558 4.776,469.334 10.667,469.334L224,469.334L224,448L21.333,448L21.333,21.333L234.666,21.333L234.666,117.333C234.666,123.224 239.442,128 245.333,128L341.333,128L341.333,213.333L341.333,213.333ZM256,106.667L256,36.427L326.219,106.667L256,106.667L256,106.667Z" fill="#175975"/>
<path d="M501.333,341.333L480,341.333L480,330.666C479.988,289.429 446.55,256.009 405.312,256.02C402.195,256.021 399.081,256.217 395.989,256.607C358.752,261.151 330.666,294.047 330.666,333.119L330.666,341.332L309.333,341.332C303.442,341.332 298.666,346.108 298.666,351.999L298.666,501.332C298.666,507.223 303.442,511.999 309.333,511.999L501.333,511.999C507.224,511.999 512,507.223 512,501.332L512,352C512,346.109 507.224,341.333 501.333,341.333L501.333,341.333ZM352,333.131C352,304.822 372.021,281.035 398.571,277.792C427.788,274.057 454.502,294.715 458.237,323.932C458.523,326.165 458.666,328.415 458.667,330.666L458.667,341.333L352,341.333L352,333.131L352,333.131ZM490.667,490.667L320,490.667L320,362.667L490.667,362.667L490.667,490.667L490.667,490.667Z" fill="#175975"/>
<path d="M394.667,423.797L394.667,458.666C394.667,464.557 399.443,469.333 405.334,469.333C411.225,469.333 416,464.558 416,458.667L416,423.563C426.103,417.57 429.435,404.522 423.443,394.419C419.605,387.948 412.633,383.986 405.11,384C393.369,383.979 383.834,393.479 383.813,405.22C383.798,412.922 387.951,420.028 394.667,423.797L394.667,423.797Z" fill="#175975"/>
</g>
<text font-size="100" font-family="'MesloLGLDZForPowerline-Bold'" fill="#175975" transform="matrix(1.24404 0 0 1.04972 34.5897 178.002)">
<tspan x="0" y="112" textLength="240.82">
<![CDATA[SBOM]]></tspan>
</text>
</g>
</svg>`,
});
@NgModule({

View File

@ -252,6 +252,18 @@ export const DEFAULT_PAGE_SIZE: number = 15;
*/
export const DEFAULT_SUPPORTED_MIME_TYPES =
'application/vnd.security.vulnerability.report; version=1.1, application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0';
/**
* The default supported mime type for SBOM
*/
export const DEFAULT_SBOM_SUPPORTED_MIME_TYPES =
'application/vnd.security.sbom.report+json; version=1.0';
/**
* The SBOM supported additional mime types
*/
export const SBOM_SUPPORTED_ADDITIONAL_MIME_TYPES = [
'application/spdx+json',
// 'application/vnd.cyclonedx+json', // feature release
];
/**
* the property name of vulnerability database updated time
@ -483,6 +495,26 @@ export function downloadFile(fileData) {
a.remove();
}
/**
* Download the Json Object as a Json file to local.
* @param data Json Object
* @param filename Json filename
*/
export function downloadJson(data, filename) {
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json;charset=utf-8',
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
document.body.appendChild(a);
a.setAttribute('style', 'display: none');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
export function getChanges(
original: any,
afterChange: any
@ -1030,6 +1062,7 @@ export enum PageSizeMapKeys {
ARTIFACT_LIST_TAB_COMPONENT = 'ArtifactListTabComponent',
ARTIFACT_TAGS_COMPONENT = 'ArtifactTagComponent',
ARTIFACT_VUL_COMPONENT = 'ArtifactVulnerabilitiesComponent',
ARTIFACT_SBOM_COMPONENT = 'ArtifactSbomComponent',
MEMBER_COMPONENT = 'MemberComponent',
LABEL_COMPONENT = 'LabelComponent',
P2P_POLICY_COMPONENT = 'P2pPolicyComponent',

View File

@ -804,6 +804,7 @@
"FILTER_BY_LABEL": "Images nach Label filtern",
"FILTER_ARTIFACT_BY_LABEL": "Artefakte nach Label filtern",
"ADD_LABELS": "Label hinzufügen",
"STOP": "Stop",
"RETAG": "Kopieren",
"ACTION": "AKTION",
"DEPLOY": "Bereitstellen",
@ -1057,7 +1058,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1107,7 +1108,7 @@
"PLACEHOLDER": "Filter Schwachstellen",
"PACKAGE": "Paket",
"PACKAGES": "Pakete",
"SCAN_NOW": "Scan",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "SCAN DURCH {{scanner}}",
"REPORTED_BY": "GEMELDET VON {{scanner}}",
"NO_SCANNER": "KEIN SCANNER",

View File

@ -805,6 +805,7 @@
"FILTER_BY_LABEL": "Filter images by label",
"FILTER_ARTIFACT_BY_LABEL": "Filter actifact by label",
"ADD_LABELS": "Add Labels",
"STOP": "Stop",
"RETAG": "Copy",
"ACTION": "ACTION",
"DEPLOY": "DEPLOY",
@ -1058,7 +1059,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM ",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1108,7 +1109,7 @@
"PLACEHOLDER": "Filter Vulnerabilities",
"PACKAGE": "package",
"PACKAGES": "packages",
"SCAN_NOW": "Scan",
"SCAN_NOW": "Scan vulnerability ",
"SCAN_BY": "SCAN BY {{scanner}}",
"REPORTED_BY": "Reported by {{scanner}}",
"NO_SCANNER": "NO SCANNER",

View File

@ -805,6 +805,7 @@
"FILTER_BY_LABEL": "Filter images by label",
"FILTER_ARTIFACT_BY_LABEL": "Filter actifact by label",
"ADD_LABELS": "Add Labels",
"STOP": "Stop",
"RETAG": "Copy",
"ACTION": "ACTION",
"DEPLOY": "DEPLOY",
@ -1056,7 +1057,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1106,7 +1107,7 @@
"PLACEHOLDER": "Filter Vulnerabilities",
"PACKAGE": "package",
"PACKAGES": "packages",
"SCAN_NOW": "Scan",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "SCAN BY {{scanner}}",
"REPORTED_BY": "Reported by {{scanner}}",
"NO_SCANNER": "NO SCANNER",

View File

@ -804,6 +804,7 @@
"FILTER_BY_LABEL": "Filtrer les images par label",
"FILTER_ARTIFACT_BY_LABEL": "Filtrer les artefact par label",
"ADD_LABELS": "Ajouter des labels",
"STOP": "Stop",
"RETAG": "Copier",
"ACTION": "Action",
"DEPLOY": "Déployer",
@ -1056,7 +1057,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1106,12 +1107,12 @@
"PLACEHOLDER": "Filtrer les vulnérabilités",
"PACKAGE": "paquet",
"PACKAGES": "paquets",
"SCAN_NOW": "Analyser",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "Scan par {{scanner}}",
"REPORTED_BY": "Rapporté par {{scanner}}",
"NO_SCANNER": "Aucun scanneur",
"TRIGGER_STOP_SUCCESS": "Déclenchement avec succès de l'arrêt d'analyse",
"STOP_NOW": "Arrêter l'analyse"
"STOP_NOW": "Stop Scan"
},
"PUSH_IMAGE": {
"TITLE": "Commande de push",

View File

@ -802,6 +802,7 @@
"FILTER_BY_LABEL": "라벨별로 이미지 필터",
"FILTER_ARTIFACT_BY_LABEL": "라벨별로 아티팩트 필터",
"ADD_LABELS": "라벨 추가",
"STOP": "Stop",
"RETAG": "복사",
"ACTION": "동작",
"DEPLOY": "배포",
@ -1055,7 +1056,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1105,12 +1106,12 @@
"PLACEHOLDER": "취약점 필터",
"PACKAGE": "패키지",
"PACKAGES": "패키지들",
"SCAN_NOW": "스캔",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "{{scanner}로 스캔",
"REPORTED_BY": "{{scanner}}로 보고 됨",
"NO_SCANNER": "스캐너 없음",
"TRIGGER_STOP_SUCCESS": "트리거 중지 스캔이 성공적으로 수행되었습니다",
"STOP_NOW": "스캔 중지"
"STOP_NOW": "Stop Scan"
},
"PUSH_IMAGE": {
"TITLE": "푸시 명령어",

View File

@ -803,6 +803,7 @@
"FILTER_BY_LABEL": "Filtrar imagens por marcadores",
"FILTER_ARTIFACT_BY_LABEL": "Filtrar por marcador",
"ADD_LABELS": "Adicionar Marcadores",
"STOP": "Stop",
"RETAG": "Copiar",
"ACTION": "AÇÃO",
"DEPLOY": "IMPLANTAR",
@ -1054,7 +1055,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1104,12 +1105,12 @@
"PLACEHOLDER": "Filtrar",
"PACKAGE": "pacote",
"PACKAGES": "pacotes",
"SCAN_NOW": "Examinar",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "EXAMINAR COM {{scanner}}",
"REPORTED_BY": "Encontrado com {{scanner}}",
"NO_SCANNER": "NENHUM",
"TRIGGER_STOP_SUCCESS": "Exame foi interrompido",
"STOP_NOW": "Interromper"
"STOP_NOW": "Stop Scan"
},
"PUSH_IMAGE": {
"TITLE": "Comando Push",

View File

@ -804,6 +804,7 @@
"FILTER_BY_LABEL": "İmajları etikete göre filtrele",
"FILTER_ARTIFACT_BY_LABEL": "Filter actifact by label",
"ADD_LABELS": "Etiketler Ekle",
"STOP": "Stop",
"RETAG": "Copy",
"ACTION": "AKSİYON",
"DEPLOY": "YÜKLE",
@ -1057,7 +1058,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1107,7 +1108,7 @@
"PLACEHOLDER": "Güvenlik Açıklarını Filtrele",
"PACKAGE": "paket",
"PACKAGES": "paketler",
"SCAN_NOW": "Tara",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "SCAN BY {{scanner}}",
"REPORTED_BY": "Reported by {{scanner}}",
"NO_SCANNER": "NO SCANNER",

View File

@ -801,6 +801,7 @@
"LABELS": "标签",
"ADD_LABEL_TO_IMAGE": "添加标签到此镜像",
"ADD_LABELS": "添加标签",
"STOP": "Stop",
"RETAG": "拷贝",
"FILTER_BY_LABEL": "过滤标签",
"FILTER_ARTIFACT_BY_LABEL": "通过标签过滤Artifact",
@ -1055,7 +1056,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",
@ -1105,7 +1106,7 @@
"PLACEHOLDER": "过滤漏洞",
"PACKAGE": "组件",
"PACKAGES": "组件",
"SCAN_NOW": "扫描",
"SCAN_NOW": "Scan vulnerability",
"SCAN_BY": "使用 {{scanner}} 进行扫描",
"REPORTED_BY": "结果由 {{scanner}} 提供",
"NO_SCANNER": "无扫描器",

View File

@ -801,6 +801,7 @@
"LABELS": "標籤",
"ADD_LABEL_TO_IMAGE": "新增標籤到此映像檔",
"ADD_LABELS": "新增標籤",
"STOP": "Stop",
"RETAG": "複製",
"FILTER_BY_LABEL": "篩選標籤",
"FILTER_ARTIFACT_BY_LABEL": "透過標籤篩選 Artifact",
@ -1054,7 +1055,7 @@
"NO_SBOM": "No SBOM",
"PACKAGES": "SBOM",
"REPORTED_BY": "Reported by {{scanner}}",
"GENERATE": "Create SBOM",
"GENERATE": "Generate SBOM",
"DOWNLOAD": "Download SBOM",
"Details": "SBOM details",
"STOP": "Stop SBOM",