draft: sbom UI feature implementation

Signed-off-by: xuelichao <xuel@vmware.com>
This commit is contained in:
xuelichao 2024-02-04 17:29:31 +08:00 committed by stonezdj(Daojun Zhang)
parent 4fd11ce072
commit 7669e3d771
23 changed files with 986 additions and 67 deletions

View File

@ -4744,7 +4744,7 @@ paths:
summary: Get job log by job id
description: Get job log by job id, it is only used by administrator
produces:
- text/plain
- text/plain
tags:
- jobservice
parameters:
@ -4875,6 +4875,7 @@ paths:
'200':
description: Get scheduler status successfully.
schema:
type: object
$ref: '#/definitions/SchedulerStatus'
'401':
$ref: '#/responses/401'
@ -6096,7 +6097,7 @@ paths:
description: Specify whether the dangerous Artifact are included inside summary information
type: boolean
required: false
default: false
default: false
responses:
'200':
description: Success
@ -6115,15 +6116,15 @@ paths:
get:
summary: Get the vulnerability list.
description: |
Get the vulnerability list. use q to pass the query condition,
Get the vulnerability list. use q to pass the query condition,
supported conditions:
cve_id(exact match)
cvss_score_v3(range condition)
severity(exact match)
repository_name(exact match)
project_id(exact match)
repository_name(exact match)
project_id(exact match)
package(exact match)
tag(exact match)
tag(exact match)
digest(exact match)
tags:
- securityhub
@ -6800,6 +6801,12 @@ definitions:
format: int64
description: 'Time in seconds required to create the report'
example: 300
complete_percent:
type: integer
description: 'The complete percent of the scanning which value is between 0 and 100'
example: 100
scanner:
$ref: '#/definitions/Scanner'
NativeReportSummary:
type: object
description: 'The summary for the native report'
@ -7221,10 +7228,6 @@ definitions:
type: string
description: 'Whether scan images automatically when pushing. The valid values are "true", "false".'
x-nullable: true
auto_sbom_generation:
type: string
description: 'Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false".'
x-nullable: true
reuse_sys_cve_allowlist:
type: string
description: 'Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false".
@ -7717,9 +7720,8 @@ definitions:
description: The level of the robot, project or system
duration:
type: integer
x-nullable: true
format: int64
description: The duration of the robot in days, duration must be either -1(Never) or a positive integer
description: The duration of the robot in days
editable:
type: boolean
x-omitempty: false
@ -7766,7 +7768,7 @@ definitions:
duration:
type: integer
format: int64
description: The duration of the robot in days, duration must be either -1(Never) or a positive integer
description: The duration of the robot in days
permissions:
type: array
items:
@ -8056,7 +8058,7 @@ definitions:
type: string
description: |
The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'.
'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and
'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and
'None' means to cancel the schedule.
enum:
- Hourly
@ -9886,12 +9888,12 @@ definitions:
type: object
description: the dangerous CVE information
properties:
cve_id:
cve_id:
type: string
description: the cve id
severity:
type: string
description: the severity of the CVE
description: the severity of the CVE
cvss_score_v3:
type: number
format: float64
@ -9901,7 +9903,7 @@ definitions:
description: the description of the CVE
package:
type: string
description: the package of the CVE
description: the package of the CVE
version:
type: string
description: the version of the package
@ -9909,14 +9911,14 @@ definitions:
type: object
description: the dangerous artifact information
properties:
project_id:
project_id:
type: integer
format: int64
description: the project id of the artifact
repository_name:
type: string
description: the repository name of the artifact
digest:
digest:
type: string
description: the digest of the artifact
critical_cnt:
@ -9976,7 +9978,7 @@ definitions:
description: The description of the vulnerability
links:
type: array
items:
items:
type: string
description: Links of the vulnerability
ScanType:
@ -9985,4 +9987,4 @@ definitions:
scan_type:
type: string
description: 'The scan type for the scan request. Two options are currently supported, vulnerability and sbom'
enum: [ vulnerability, sbom ]
enum: [ vulnerability, sbom ]

View File

@ -1,7 +1,11 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { DEFAULT_SUPPORTED_MIME_TYPES } from '../../../../../shared/units/utils';
import {
DEFAULT_SBOM_SUPPORTED_MIME_TYPES,
DEFAULT_SUPPORTED_MIME_TYPES,
} from '../../../../../shared/units/utils';
import { ScanTypes } from 'src/app/shared/entities/shared.const';
@Injectable({
providedIn: 'root',
@ -12,7 +16,8 @@ export class AdditionsService {
getDetailByLink(
link: string,
shouldSetHeader: boolean,
shouldReturnText: boolean
shouldReturnText: boolean,
scanType = ScanTypes.VULNERABILITY
): Observable<any> {
if (shouldReturnText) {
return this.http.get(link, {
@ -22,9 +27,16 @@ export class AdditionsService {
}
if (shouldSetHeader) {
return this.http.get(link, {
headers: {
'X-Accept-Vulnerabilities': DEFAULT_SUPPORTED_MIME_TYPES,
},
headers:
scanType === ScanTypes.SBOM
? {
'X-Accept-SBOMs':
DEFAULT_SBOM_SUPPORTED_MIME_TYPES,
}
: {
'X-Accept-Vulnerabilities':
DEFAULT_SUPPORTED_MIME_TYPES,
},
});
}
return this.http.get(link);

View File

@ -18,6 +18,20 @@
"></hbr-artifact-vulnerabilities>
</clr-tab-content>
</clr-tab>
<clr-tab *ngIf="getSbom()">
<button clrTabLink id="sbom">
{{ 'REPOSITORY.SBOM' | translate }}
</button>
<clr-tab-content id="sbom-content" *clrIfActive>
<hbr-artifact-sbom
[artifact]="artifact"
[projectName]="projectName"
[projectId]="projectId"
[repoName]="repoName"
[sbomDigest]="sbomDigest"
[sbomLink]="getSbom()"></hbr-artifact-sbom>
</clr-tab-content>
</clr-tab>
<clr-tab *ngIf="getBuildHistory()">
<button clrTabLink id="build-history">
{{ 'REPOSITORY.BUILD_HISTORY' | translate }}

View File

@ -19,6 +19,8 @@ export class ArtifactAdditionsComponent {
repoName: string;
@Input()
digest: string;
@Input()
sbomDigest: string;
constructor() {}
getVulnerability(): AdditionLink {
@ -30,6 +32,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];

View File

@ -0,0 +1,84 @@
<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'">{{
'SBOM.GRID.COLUMN_PACKAGE' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgField]="'version'">{{
'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>{{ res.name ?? '' }}</clr-dg-cell>
<clr-dg-cell>{{ 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?.scanner">{{
'SBOM.REPORTED_BY'
| translate
: { scanner: getScannerInfo(artifact?.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,64 @@
.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;
}

View File

@ -0,0 +1,188 @@
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';
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 mockedLink: AdditionLink = {
absolute: false,
href: '/test',
};
const fakedArtifactService = {
getSbomAddition() {
return of(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';
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 },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ArtifactSbomComponent);
component = fixture.componentInstance;
component.hasSbomPermission = true;
component.hasEnabledSbom = true;
component.sbomLink = mockedLink;
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,236 @@
import { 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';
@Component({
selector: 'hbr-artifact-sbom',
templateUrl: './artifact-sbom.component.html',
styleUrls: ['./artifact-sbom.component.scss'],
})
export class ArtifactSbomComponent implements OnInit, OnDestroy {
@Input()
sbomLink: AdditionLink;
@Input()
projectName: string;
@Input()
projectId: number;
@Input()
repoName: string;
@Input()
sbomDigest: string;
@Input() artifact: Artifact;
artifactSbom: ArtifactSbom;
loading: boolean = false;
hasEnabledSbom: 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 userPermissionService: UserPermissionService,
private eventService: EventService,
private session: SessionService
) {}
ngOnInit() {
this.getSbom();
this.getSbomPermission();
this.hasEnabledSbom = this.appConfigService.getConfig().sbom_enabled;
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 &&
this.sbomLink &&
!this.sbomLink.absolute &&
this.sbomLink.href
) {
if (!this.hasShowLoading) {
this.loading = true;
this.hasShowLoading = true;
}
const sbomAdditionParams = <ArtifactService.GetSbomAdditionParams>{
repositoryName: dbEncodeURIComponent(this.repoName),
reference: this.sbomDigest,
projectName: this.projectName,
};
this.artifactService
.getSbomAddition(sbomAdditionParams)
.pipe(
finalize(() => {
this.loading = false;
this.hasShowLoading = false;
})
)
.subscribe(
res => {
this.artifactSbom = getArtifactSbom(res);
},
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.sbom_status ===
SBOM_SCAN_STATUS.PENDING ||
this.artifact.sbom_overview.sbom_status ===
SBOM_SCAN_STATUS.RUNNING)
);
}
downloadSbom() {
this.downloadSbomBtnState = ClrLoadingState.LOADING;
if (
this.artifact?.sbom_overview?.sbom_status ===
SBOM_SCAN_STATUS.SUCCESS
) {
downloadJson(
this.artifactSbom.sbomJsonRaw,
`${this.artifactSbom.sbomName}.json`
);
}
this.downloadSbomBtnState = ClrLoadingState.DEFAULT;
}
canDownloadSbom(): boolean {
return (
this.hasEnabledSbom &&
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

@ -170,8 +170,6 @@ export class ArtifactListPageService {
this._hasDeleteImagePermission = results[2];
this._hasScanImagePermission = results[3];
this._hasSbomPermission = results?.[4] ?? false;
// TODO need to remove the static code
this._hasSbomPermission = true;
},
error => this.errorHandlerService.error(error)
);

View File

@ -24,6 +24,7 @@
canScanNow() &&
selectedRowHasVul() &&
hasEnabledScanner &&
hasScannerSupportVulnerability &&
hasScanImagePermission
)
"
@ -48,7 +49,15 @@
[clrLoading]="generateSbomBtnState"
type="button"
class="btn btn-secondary"
[disabled]="true"
[disabled]="
!(
canGenerateSbomNow() &&
selectedRowHasSbom() &&
hasEnabledSbom &&
hasScannerSupportSBOM &&
hasSbomPermission
)
"
(click)="generateSbom()">
<clr-icon shape="file" size="16"></clr-icon>&nbsp;
<span>{{ 'SBOM.GENERATE' | translate }}</span>

View File

@ -46,6 +46,7 @@ import {
ConfirmationButtons,
ConfirmationState,
ConfirmationTargets,
ScanTypes,
} from '../../../../../../../shared/entities/shared.const';
import {
operateChanges,
@ -101,6 +102,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
projectName: string;
repoName: string;
registryUrl: string;
sbomEnabled: boolean;
artifactList: ArtifactFront[] = [];
availableTime = AVAILABLE_TIME;
inprogress: boolean;
@ -190,7 +192,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
false,
false,
false,
true,
false,
true,
false,
false,
@ -238,6 +240,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
ngOnInit() {
const appConfig = this.appConfigService.getConfig();
this.registryUrl = appConfig.registry_url;
this.sbomEnabled = appConfig.sbom_enabled;
this.initRouterData();
if (!this.updateArtifactSub) {
this.updateArtifactSub = this.eventService.subscribe(
@ -360,7 +363,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 +388,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 +438,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,
@ -800,6 +804,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 +840,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 !!(
@ -1019,18 +1059,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 +1077,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 +1119,6 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return false;
}
// return true if all selected rows are in "running" state
canStopSbom(): boolean {
if (this.onSendingStopSbomCommand) {
@ -1084,7 +1127,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
if (this.selectedRow && this.selectedRow.length) {
let flag: boolean = true;
this.selectedRow.forEach(item => {
const st: string = this.sbomStatus(item);
const st: string = this.scanStatus(item);
if (!this.isRunningState(st)) {
flag = false;
}
@ -1142,6 +1185,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
return null;
}
deleteAccessory(a: Accessory) {
let titleKey: string,
summaryKey: string,

View File

@ -59,6 +59,7 @@
[projectId]="projectId"
[repoName]="repositoryName"
[digest]="artifactDigest"
[sbomDigest]="sbomDigest"
[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,7 @@ import {
export class ArtifactSummaryComponent implements OnInit {
tagId: string;
artifactDigest: string;
sbomDigest?: string;
repositoryName: string;
projectId: string | number;
referArtifactNameArray: string[] = [];
@ -37,10 +35,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 +95,7 @@ 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'];
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,200 @@ 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 sbomJson[ArtifactSbomFieldMapper.sbomVersion] ||
sbomJson[ArtifactSbomFieldMapper.sbomId]
? true
: false;
}
/**
* 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) {
// only support to parse spdx json
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

@ -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,
withScanOverview: 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) {

View File

@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ScannerVo, SbomSummary } from '../../../../../../shared/services';
@ -20,8 +20,9 @@ const SUCCESS_PCT: number = 100;
templateUrl: './sbom-tip-histogram.component.html',
styleUrls: ['./sbom-tip-histogram.component.scss'],
})
export class SbomTipHistogramComponent {
export class SbomTipHistogramComponent implements OnInit {
@Input() scanner: ScannerVo;
_sbomPackages: number = 0;
@Input() sbomSummary: SbomSummary = {
scan_status: SBOM_SCAN_STATUS.NOT_GENERATED_SBOM,
};
@ -33,6 +34,22 @@ export class SbomTipHistogramComponent {
private router: Router
) {}
ngOnInit(): void {
this._sbomPackages = this.sbomSummary?.summary?.total ?? 0;
}
get sbomPackages(): number {
return this._sbomPackages;
}
get sevSummary(): { [key: string]: number } {
if (this.sbomSummary && this.sbomSummary.summary) {
return this.sbomSummary.summary.summary;
}
return null;
}
duration(): string {
if (this.sbomSummary && this.sbomSummary.duration) {
let str = '';
@ -54,6 +71,7 @@ export class SbomTipHistogramComponent {
? `100%`
: '0%';
}
isLimitedSuccess(): boolean {
return (
this.sbomSummary && this.sbomSummary.complete_percent < SUCCESS_PCT

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

@ -29,6 +29,7 @@ export class AppConfig {
registry_storage_provider_name: string;
read_only: boolean;
show_popular_repo: boolean;
sbom_enabled: boolean;
banner_message: string;
current_time: string;
oidc_provider_name: string;
@ -43,6 +44,7 @@ export class AppConfig {
this.project_creation_restriction = 'everyone';
this.self_registration = true;
this.has_ca_root = false;
this.sbom_enabled = false;
this.harbor_version = 'unknown';
this.clair_vulnerability_status = {
overall_last_update: 0,

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

@ -213,9 +213,12 @@ export interface VulnerabilitySummary {
}
export interface SbomSummary {
report_id?: string;
mime_type?: string;
sbom_digest?: string;
scan_status?: string;
severity?: string;
duration?: number;
summary?: SeveritySummary;
start_time?: Date;
end_time?: Date;
scanner?: ScannerVo;

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',