diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue index 69733973e7cc1ca423744b42411d5c0e6d3c375e..2c6f1da367f5e7848099cc3a9ff94c6980d49203 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue @@ -22,6 +22,10 @@ export default { import( 'ee_component/packages_and_registries/container_registry/explorer/components/list_page/metadata_container_scanning.vue' ), + ContainerScanningCounts: () => + import( + 'ee_component/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts.vue' + ), }, inject: ['config'], props: { @@ -123,5 +127,9 @@ export default { <template v-if="!config.isGroupPage" #metadata-container-scanning> <metadata-container-scanning /> </template> + + <template v-if="!config.isGroupPage"> + <container-scanning-counts /> + </template> </title-area> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js index 1e22469edd19dd88c6ebd66613717d29a158ac3e..dacc621bed7b2361e40cfefb8135e5f66284008c 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js @@ -43,6 +43,7 @@ export default () => { invalidPathError, securityConfigurationPath, containerScanningForRegistryDocsPath, + vulnerabilityReportPath, ...config } = el.dataset; @@ -80,6 +81,7 @@ export default () => { isMetadataDatabaseEnabled: parseBoolean(isMetadataDatabaseEnabled), securityConfigurationPath, containerScanningForRegistryDocsPath, + vulnerabilityReportPath, }, /* eslint-disable @gitlab/require-i18n-strings */ dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`, diff --git a/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts.vue b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts.vue new file mode 100644 index 0000000000000000000000000000000000000000..7cda9aff4b0f90fac5346bde02492455494974d8 --- /dev/null +++ b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts.vue @@ -0,0 +1,133 @@ +<script> +import { GlSkeletonLoader, GlSprintf, GlIcon, GlLink } from '@gitlab/ui'; +import countsQuery from 'ee/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql'; +import { REPORT_TYPE_PRESETS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants'; +import { + VULNERABILITY_STATE_OBJECTS, + CRITICAL, + HIGH, + MEDIUM, + LOW, + INFO, + UNKNOWN, + SEVERITY_COUNT_LIMIT, + SEVERITIES, +} from 'ee/vulnerabilities/constants'; +import { s__, sprintf } from '~/locale'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +const { detected, confirmed } = VULNERABILITY_STATE_OBJECTS; + +export default { + components: { GlSkeletonLoader, GlSprintf, GlIcon, GlLink }, + inject: ['config'], + data() { + return { + project: {}, + }; + }, + i18n: { + highlights: s__( + 'ciReport|%{criticalStart}critical%{criticalEnd}, %{highStart}high%{highEnd} and %{otherStart}other%{otherEnd} vulnerabilities detected.', + ), + latestTagsOnly: s__('ciReport|Runs against latest tags only'), + viewVulnerabilities: s__('ciReport|View vulnerabilities'), + }, + computed: { + isEnabled() { + return ( + this.project?.containerScanningForRegistry.isEnabled && + this.project?.containerScanningForRegistry.isVisible + ); + }, + isLoadingCounts() { + return this.$apollo.queries.project.loading; + }, + severityCounts() { + return SEVERITIES.map((severity) => ({ + severity, + count: this.counts[severity] || 0, + })); + }, + counts() { + return this.project?.vulnerabilitySeveritiesCount || {}; + }, + criticalSeverity() { + return this.formattedCounts(this.counts[CRITICAL]); + }, + highSeverity() { + return this.formattedCounts(this.counts[HIGH]); + }, + otherSeverity() { + let totalCounts = 0; + + [MEDIUM, LOW, INFO, UNKNOWN].forEach((severity) => { + const count = this.counts[severity]; + + if (count) { + totalCounts += count; + } + }); + + return this.formattedCounts(totalCounts); + }, + }, + apollo: { + project: { + query: countsQuery, + errorPolicy: 'none', + variables() { + return { + fullPath: this.config.projectPath, + securityConfigurationPath: this.config.securityConfigurationPath, + reportType: REPORT_TYPE_PRESETS.CONTAINER_REGISTRY, + state: [detected.searchParamValue, confirmed.searchParamValue], + capped: true, + }; + }, + update(data) { + return data.project; + }, + error(e) { + Sentry.captureException(e); + }, + }, + }, + methods: { + formattedCounts(count) { + return count > SEVERITY_COUNT_LIMIT + ? sprintf(s__('SecurityReports|%{count}+'), { count: SEVERITY_COUNT_LIMIT }) + : count; + }, + }, +}; +</script> + +<template> + <div> + <gl-skeleton-loader v-if="isLoadingCounts" :equal-width-lines="true" :lines="3" /> + <div v-else-if="isEnabled" class="gl-border gl-my-6 gl-p-5 gl-text-base"> + <div data-testid="counts"> + <gl-sprintf :message="$options.i18n.highlights"> + <template #critical="{ content }" + ><strong class="gl-text-red-800">{{ criticalSeverity }} {{ content }}</strong></template + > + <template #high="{ content }" + ><strong class="gl-text-red-600">{{ highSeverity }} {{ content }}</strong></template + > + <template #other="{ content }" + ><strong>{{ otherSeverity }} {{ content }}</strong></template + > + </gl-sprintf> + <gl-link :href="config.vulnerabilityReportPath">{{ + $options.i18n.viewVulnerabilities + }}</gl-link> + </div> + + <div class="gl-pt-2 gl-text-secondary"> + <gl-icon name="information-o" /> + {{ $options.i18n.latestTagsOnly }} + </div> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/metadata_container_scanning.vue b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/metadata_container_scanning.vue index 0c3d47f7e7d4f390f4fd93c433d059e11c02f96d..3532a2d15bf57758a4e9795887a959ee5b10afb4 100644 --- a/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/metadata_container_scanning.vue +++ b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/metadata_container_scanning.vue @@ -1,9 +1,12 @@ <script> import { GlSkeletonLoader, GlIcon, GlPopover, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { fetchPolicies } from '~/lib/graphql'; +import { REPORT_TYPE_PRESETS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants'; +import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; import getProjectContainerScanning from '../../graphql/queries/get_project_container_scanning.query.graphql'; +const { detected, confirmed } = VULNERABILITY_STATE_OBJECTS; + export default { components: { GlIcon, @@ -20,14 +23,17 @@ export default { return { fullPath: this.config.projectPath, securityConfigurationPath: this.config.securityConfigurationPath, + reportType: REPORT_TYPE_PRESETS.CONTAINER_REGISTRY, + state: [detected.searchParamValue, confirmed.searchParamValue], }; }, - // We need this for handling loading state when using frontend cache - // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details - fetchPolicy: fetchPolicies.CACHE_ONLY, - notifyOnNetworkStatusChange: true, update(data) { - return data.project.containerScanningForRegistry ?? { isEnabled: false, isVisible: false }; + return ( + data.project.containerScanningForRegistry ?? { + isEnabled: false, + isVisible: false, + } + ); }, }, }, diff --git a/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql index ea4ebff1737ddd9b9013a744c1a95527cf223d8b..4d739d144a7926d10e8366ccb500c8ec5883ec22 100644 --- a/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql +++ b/ee/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql @@ -1,9 +1,20 @@ -query getProjectContainerScanning($fullPath: ID!, $securityConfigurationPath: String!) { +#import "ee/security_dashboard/graphql/fragments/vulnerability_severities_count.fragment.graphql" + +query getProjectContainerScanning( + $fullPath: ID = "" + $securityConfigurationPath: String! + $reportType: [VulnerabilityReportType!] + $state: [VulnerabilityState!] + $capped: Boolean = false +) { project(fullPath: $fullPath) { id containerScanningForRegistry(securityConfigurationPath: $securityConfigurationPath) @client { isEnabled isVisible } + vulnerabilitySeveritiesCount(reportType: $reportType, state: $state, capped: $capped) { + ...VulnerabilitySeveritiesCount + } } } diff --git a/ee/app/helpers/ee/container_registry/container_registry_helper.rb b/ee/app/helpers/ee/container_registry/container_registry_helper.rb index df5a79ac43bea7091479469ba0508fc196c851d6..7311931723be484f1276167c848b0389ae746e62 100644 --- a/ee/app/helpers/ee/container_registry/container_registry_helper.rb +++ b/ee/app/helpers/ee/container_registry/container_registry_helper.rb @@ -9,6 +9,8 @@ module ContainerRegistryHelper def project_container_registry_template_data(project, connection_error, invalid_path_error) super.merge( security_configuration_path: project_security_configuration_path(project), + vulnerability_report_path: project_security_vulnerability_report_index_path(project, + tab: :CONTAINER_REGISTRY), container_scanning_for_registry_docs_path: help_page_path('user/application_security/continuous_vulnerability_scanning/index') ) diff --git a/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts_spec.js b/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a069009316e420305bee76e369ff3ab3a3b62c05 --- /dev/null +++ b/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts_spec.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlSkeletonLoader, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getProjectContainerScanning } from 'ee/packages_and_registries/container_registry/explorer/graphql/queries/get_project_container_scanning.query.graphql'; +import component from 'ee/packages_and_registries/container_registry/explorer/components/list_page/container_scanning_counts.vue'; + +import { + graphQLProjectContainerScanningForRegistryOnMock, + graphQLProjectContainerScanningForRegistryOnMockCapped, + graphQLProjectContainerScanningForRegistryOffMock, +} from './mock_data'; + +Vue.use(VueApollo); + +describe('Container Scanning Counts', () => { + let apolloProvider; + let wrapper; + + const findCounts = () => wrapper.findByTestId('counts'); + const findVulnerabilityReportlink = () => findCounts().findComponent(GlLink); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + }; + + const mountComponent = ({ + config = { + projectPath: 'project', + securityConfigurationPath: '/path', + vulnerabilityReportPath: '/vulnerability/report/path', + }, + requestHandlers, + containerScanningForRegistryMock, + } = {}) => { + const cacheOptions = { + typePolicies: { + Project: { + fields: { + containerScanningForRegistry: { + read() { + return containerScanningForRegistryMock; + }, + }, + }, + }, + }, + }; + + apolloProvider = createMockApollo(requestHandlers, {}, cacheOptions); + + apolloProvider.clients.defaultClient.writeQuery({ + query: getProjectContainerScanning, + variables: { + fullPath: config.projectPath, + securityConfigurationPath: config.securityConfigurationPath, + capped: true, + }, + data: graphQLProjectContainerScanningForRegistryOnMock.data, + }); + + wrapper = shallowMountExtended(component, { + apolloProvider, + provide() { + return { + config, + }; + }, + stubs: { + GlSprintf, + }, + }); + }; + + it('renders the counts after skeleton loader', async () => { + const requestHandlers = [ + [ + getProjectContainerScanning, + jest.fn().mockResolvedValue(graphQLProjectContainerScanningForRegistryOnMock), + ], + ]; + + const containerScanningForRegistryMock = + graphQLProjectContainerScanningForRegistryOnMock.data.project.containerScanningForRegistry; + + mountComponent({ requestHandlers, containerScanningForRegistryMock }); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForApolloRequestRender(); + + expect(findCounts().text()).toBe( + '3 critical, 12 high and 35 other vulnerabilities detected. View vulnerabilities', + ); + expect(findVulnerabilityReportlink().attributes('href')).toBe('/vulnerability/report/path'); + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('renders the counts with capped limit', async () => { + const requestHandlers = [ + [ + getProjectContainerScanning, + jest.fn().mockResolvedValue(graphQLProjectContainerScanningForRegistryOnMockCapped), + ], + ]; + + const containerScanningForRegistryMock = + graphQLProjectContainerScanningForRegistryOnMockCapped.data.project + .containerScanningForRegistry; + + mountComponent({ requestHandlers, containerScanningForRegistryMock }); + + await waitForApolloRequestRender(); + + expect(findCounts().text()).toBe( + '1000+ critical, 20 high and 1000+ other vulnerabilities detected. View vulnerabilities', + ); + expect(findVulnerabilityReportlink().attributes('href')).toBe('/vulnerability/report/path'); + }); + + it('does not render when disabled', async () => { + const requestHandlers = [ + [ + getProjectContainerScanning, + jest.fn().mockResolvedValue(graphQLProjectContainerScanningForRegistryOffMock), + ], + ]; + + const containerScanningForRegistryMock = + graphQLProjectContainerScanningForRegistryOffMock.data.project.containerScanningForRegistry; + + mountComponent({ requestHandlers, containerScanningForRegistryMock }); + + await waitForApolloRequestRender(); + + expect(findCounts().exists()).toBe(false); + }); +}); diff --git a/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/mock_data.js b/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/mock_data.js index 872b9c16b4c43dbd7a510c5f9747b58c7bc0c5e9..3c667a8b7d94c42dc362d4c4a6304adf754e6133 100644 --- a/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/mock_data.js +++ b/ee/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/mock_data.js @@ -7,6 +7,38 @@ export const graphQLProjectContainerScanningForRegistryOnMock = { isVisible: true, __typename: 'LocalContainerScanningForRegistry', }, + vulnerabilitySeveritiesCount: { + critical: 3, + high: 12, + info: 5, + low: 9, + medium: 1, + unknown: 20, + __typename: 'VulnerabilitySeveritiesCount', + }, + __typename: 'Project', + }, + }, +}; + +export const graphQLProjectContainerScanningForRegistryOnMockCapped = { + data: { + project: { + id: '1', + containerScanningForRegistry: { + isEnabled: true, + isVisible: true, + __typename: 'LocalContainerScanningForRegistry', + }, + vulnerabilitySeveritiesCount: { + critical: 2000, + high: 20, + info: 2000, + low: 2000, + medium: 2000, + unknown: 2000, + __typename: 'VulnerabilitySeveritiesCount', + }, __typename: 'Project', }, }, @@ -21,6 +53,15 @@ export const graphQLProjectContainerScanningForRegistryOffMock = { isVisible: true, __typename: 'LocalContainerScanningForRegistry', }, + vulnerabilitySeveritiesCount: { + critical: 0, + high: 0, + info: 0, + low: 0, + medium: 0, + unknown: 0, + __typename: 'VulnerabilitySeveritiesCount', + }, __typename: 'Project', }, }, @@ -35,6 +76,15 @@ export const graphQLProjectContainerScanningForRegistryHiddenMock = { isVisible: false, __typename: 'LocalContainerScanningForRegistry', }, + vulnerabilitySeveritiesCount: { + critical: 0, + high: 0, + info: 0, + low: 0, + medium: 0, + unknown: 0, + __typename: 'VulnerabilitySeveritiesCount', + }, __typename: 'Project', }, }, diff --git a/ee/spec/helpers/container_registry/container_registry_helper_spec.rb b/ee/spec/helpers/container_registry/container_registry_helper_spec.rb index 5eeafd7661d16504241a6f37d6548cdccc0b3be5..9e500a86d9917a731334a8faf5da531501f87414 100644 --- a/ee/spec/helpers/container_registry/container_registry_helper_spec.rb +++ b/ee/spec/helpers/container_registry/container_registry_helper_spec.rb @@ -26,6 +26,8 @@ expect(project_container_registry_template_data).to include( security_configuration_path: helper.project_security_configuration_path(project), + vulnerability_report_path: helper.project_security_vulnerability_report_index_path(project, + tab: :CONTAINER_REGISTRY), container_scanning_for_registry_docs_path: help_page_path('user/application_security/continuous_vulnerability_scanning/index') ) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8ca4d2487b069f0c8bf8fce55fd44a125e45b1c3..2b22a96b1837c5e0029bcf474b8c929a63677591 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -64034,6 +64034,9 @@ msgstr[1] "" msgid "ci secure files" msgstr "" +msgid "ciReport|%{criticalStart}critical%{criticalEnd}, %{highStart}high%{highEnd} and %{otherStart}other%{otherEnd} vulnerabilities detected." +msgstr "" + msgid "ciReport|%{criticalStart}critical%{criticalEnd}, %{highStart}high%{highEnd} and %{otherStart}others%{otherEnd}" msgstr "" @@ -64242,6 +64245,9 @@ msgstr "" msgid "ciReport|Resolve with scanner suggestion" msgstr "" +msgid "ciReport|Runs against latest tags only" +msgstr "" + msgid "ciReport|SAST" msgstr "" @@ -64307,6 +64313,9 @@ msgstr "" msgid "ciReport|View full report" msgstr "" +msgid "ciReport|View vulnerabilities" +msgstr "" + msgid "ciReport|in" msgstr "" diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index 47ac3e6bd2a90c0e2938101ae33a1438b3b4a47b..12a11721685df5a8491af479fb4ac690771eed4e 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -29,6 +29,7 @@ describe('registry_header', () => { GlSprintf, TitleArea, MetadataContainerScanning: true, + ContainerScanningCounts: true, }, propsData, slots, diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js index dc5ff35bc6c8ed05784824a9aa07764802c102a2..b6d10cafe5f9aafa01626cd866f975211acdc838 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -105,6 +105,7 @@ describe('List Page', () => { TitleArea, DeleteImage, MetadataContainerScanning: true, + ContainerScanningCounts: true, }, mocks: { $toast,