diff --git a/app/assets/javascripts/security_configuration/components/continous_container_registry_scan.vue b/app/assets/javascripts/security_configuration/components/continous_container_registry_scan.vue new file mode 100644 index 0000000000000000000000000000000000000000..7898d55f09164975020bbef87b25207328cfc068 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/continous_container_registry_scan.vue @@ -0,0 +1,117 @@ +<script> +import { GlToggle, GlLink, GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import SetContainerScanningForRegistry from '~/security_configuration/graphql/set_container_scanning_for_registry.graphql'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + components: { GlToggle, GlLink, GlAlert, GlLoadingIcon }, + mixins: [glFeatureFlagsMixin()], + inject: ['containerScanningForRegistryEnabled', 'projectFullPath'], + i18n: { + title: s__('CVS|Continuous Container Scanning'), + description: s__( + 'CVS|Scan for vulnerabilities when a container image or the advisory database is updated.', + ), + learnMore: __('Learn more'), + }, + props: { + feature: { + type: Object, + required: true, + }, + }, + data() { + return { + toggleValue: this.containerScanningForRegistryEnabled, + errorMessage: '', + isRunningMutation: false, + }; + }, + computed: { + isFeatureConfigured() { + return this.feature.available && this.feature.configured; + }, + }, + methods: { + reportError(error) { + this.errorMessage = error; + }, + clearError() { + this.errorMessage = ''; + }, + async toggleCVS(checked) { + const oldValue = this.toggleValue; + + try { + this.isRunningMutation = true; + this.toggleValue = checked; + + this.clearError(); + + const { data } = await this.$apollo.mutate({ + mutation: SetContainerScanningForRegistry, + variables: { + input: { + projectPath: this.projectFullPath, + enable: checked, + }, + }, + }); + + const { errors } = data.setContainerScanningForRegistry; + + if (errors.length > 0) { + throw new Error(errors[0].message); + } else { + this.toggleValue = + data.setContainerScanningForRegistry.containerScanningForRegistryEnabled; + } + } catch (error) { + this.toggleValue = oldValue; + this.reportError(error); + } finally { + this.isRunningMutation = false; + } + }, + }, + CVSHelpPagePath: helpPagePath( + 'user/application_security/continuous_vulnerability_scanning/index', + ), +}; +</script> + +<template> + <div v-if="glFeatures.containerScanningForRegistry"> + <h4 class="gl-font-base gl-mt-6"> + {{ $options.i18n.title }} + </h4> + <gl-alert + v-if="errorMessage" + class="gl-mb-5 gl-mt-2" + variant="danger" + @dismiss="errorMessage = ''" + >{{ errorMessage }}</gl-alert + > + + <div class="gl-display-flex gl-align-items-center"> + <gl-toggle + :disabled="!isFeatureConfigured || isRunningMutation" + :value="toggleValue" + :label="s__('CVS|Toggle CVS')" + label-position="hidden" + @change="toggleCVS" + /> + <gl-loading-icon v-if="isRunningMutation" inline class="gl-ml-3" /> + </div> + + <p class="gl-mb-0 gl-mt-5"> + {{ $options.i18n.description }} + <gl-link :href="$options.CVSHelpPagePath" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + <br /> + </p> + </div> +</template> diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue index 2100da78219a54d55c9c74d715fd9153d3642a19..74736bfb62276e0976ff2b91f8e6746835f3b62e 100644 --- a/app/assets/javascripts/security_configuration/components/feature_card.vue +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -218,5 +218,7 @@ export default { {{ $options.i18n.configurationGuide }} </gl-button> </div> + + <component :is="feature.slotComponent" v-if="feature.slotComponent" :feature="feature" /> </gl-card> </template> diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index f8ab8b4685a1deab4303c5337196b760f7accaf1..46dbc2a1c93d4271097185391cbf1782fcf8a5b4 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -8,12 +8,15 @@ import { REPORT_TYPE_SAST, REPORT_TYPE_SAST_IAC, REPORT_TYPE_SECRET_DETECTION, + REPORT_TYPE_CONTAINER_SCANNING, } from '~/vue_shared/security_reports/constants'; import configureSastMutation from './graphql/configure_sast.mutation.graphql'; import configureSastIacMutation from './graphql/configure_iac.mutation.graphql'; import configureSecretDetectionMutation from './graphql/configure_secret_detection.mutation.graphql'; +import ContinuousContainerRegistryScan from './components/continous_container_registry_scan.vue'; + /** * Translations for Security Configuration Page * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. @@ -61,6 +64,12 @@ export const SCANNER_NAMES_MAP = { GENERIC: s__('ciReport|Manually added'), }; +export const securityFeatures = { + [REPORT_TYPE_CONTAINER_SCANNING]: { + slotComponent: ContinuousContainerRegistryScan, + }, +}; + export const featureToMutationMap = { [REPORT_TYPE_SAST]: { mutationId: 'configureSast', diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index 40c826613050309ddb08cf1f36f6602e8113cdcd..54de63bc8030e808cacfe44b2d616af860f802d0 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; import { augmentFeatures } from './utils'; +import { securityFeatures } from './constants'; export const initSecurityConfiguration = (el) => { if (!el) { @@ -25,9 +26,13 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + containerScanningForRegistryEnabled, } = el.dataset; - const { augmentedSecurityFeatures } = augmentFeatures(features ? JSON.parse(features) : []); + const { augmentedSecurityFeatures } = augmentFeatures( + securityFeatures, + features ? JSON.parse(features) : [], + ); return new Vue({ el, @@ -39,6 +44,7 @@ export const initSecurityConfiguration = (el) => { autoDevopsHelpPagePath, autoDevopsPath, vulnerabilityTrainingDocsPath, + containerScanningForRegistryEnabled, }, render(createElement) { return createElement(SecurityConfigurationApp, { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 23f86b30445ca3367bc5c374785933602271cf84..aa0f4bf92cd4831a3c8b368f1a45923ea33d57a0 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -10,10 +10,11 @@ import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants'; * This function takes the nested securityFeatures config and flattens it to the top level object. * It then filters out any scanner features that lack a security config for rednering in the UI * @param [{}] features + * @param {Object} securityFeatures Object containing client side UI options * @returns {Object} Object with enriched features from constants divided into Security and Compliance Features */ -export const augmentFeatures = (features = []) => { +export const augmentFeatures = (securityFeatures, features = []) => { const featuresByType = features.reduce((acc, feature) => { acc[feature.type] = convertObjectPropsToCamelCase(feature, { deep: true }); return acc; @@ -30,6 +31,7 @@ export const augmentFeatures = (features = []) => { const augmented = { ...feature, ...featuresByType[feature.type], + ...securityFeatures[feature.type], }; // Secondary layer copies some values from the first layer diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml index b9618558908896d98fdefc37f4344c8ec548d97e..579ee08383368b3d49af7b21948eecb92ba9d2f3 100644 --- a/config/known_invalid_graphql_queries.yml +++ b/config/known_invalid_graphql_queries.yml @@ -1,3 +1,4 @@ --- filenames: - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql + - app/assets/javascripts/security_configuration/graphql/set_container_scanning_for_registry.graphql \ No newline at end of file diff --git a/ee/app/controllers/ee/projects/security/configuration_controller.rb b/ee/app/controllers/ee/projects/security/configuration_controller.rb index f67e588944af96a65828b838885f8a9659a4483d..074d3ab6e21108f7113ba4cc61ab3142a4574551 100644 --- a/ee/app/controllers/ee/projects/security/configuration_controller.rb +++ b/ee/app/controllers/ee/projects/security/configuration_controller.rb @@ -12,6 +12,10 @@ module ConfigurationController before_action :ensure_security_dashboard_feature_enabled!, except: [:show] before_action :authorize_read_security_dashboard!, except: [:show] + before_action only: [:show] do + push_frontend_feature_flag(:container_scanning_for_registry) + end + feature_category :static_application_security_testing, [:show] urgency :low, [:show] diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 128ffd4466c329d86c6d23f0928024c563fb8f5f..e9b915f784cc03885ce8c10cf251479cdd7fcdb6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -9924,6 +9924,15 @@ msgstr "" msgid "CVE|Why Request a CVE ID?" msgstr "" +msgid "CVS|Continuous Container Scanning" +msgstr "" + +msgid "CVS|Scan for vulnerabilities when a container image or the advisory database is updated." +msgstr "" + +msgid "CVS|Toggle CVS" +msgstr "" + msgid "Cadence is not automated" msgstr "" diff --git a/spec/frontend/security_configuration/components/continuous_container_registry_scan_spec.js b/spec/frontend/security_configuration/components/continuous_container_registry_scan_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..046e0b16c40824c133e4564c1631d6d127b1c567 --- /dev/null +++ b/spec/frontend/security_configuration/components/continuous_container_registry_scan_spec.js @@ -0,0 +1,129 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlToggle } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import SetContainerScanningForRegistry from '~/security_configuration/graphql/set_container_scanning_for_registry.graphql'; +import ContinuousContainerRegistryScan from '~/security_configuration/components/continous_container_registry_scan.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +Vue.use(VueApollo); + +const getSetCVSMockResponse = (enabled = true) => ({ + data: { + setContainerScanningForRegistry: { + containerScanningForRegistryEnabled: enabled, + errors: [], + }, + }, +}); + +const defaultProvide = { + containerScanningForRegistryEnabled: true, + projectFullPath: 'project/full/path', +}; + +describe('ContinuousContainerRegistryScan', () => { + let wrapper; + let apolloProvider; + let requestHandlers; + + const createComponent = (options = {}) => { + requestHandlers = { + setCVSMutationHandler: jest.fn().mockResolvedValue(getSetCVSMockResponse(options.enabled)), + }; + + apolloProvider = createMockApollo([ + [SetContainerScanningForRegistry, requestHandlers.setCVSMutationHandler], + ]); + + wrapper = shallowMount(ContinuousContainerRegistryScan, { + propsData: { + feature: { + available: true, + configured: true, + }, + }, + provide: { + glFeatures: { + containerScanningForRegistry: true, + }, + ...defaultProvide, + }, + apolloProvider, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + apolloProvider = null; + }); + + const findToggle = () => wrapper.findComponent(GlToggle); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toContain('Continuous Container Scanning'); + }); + + it('renders the toggle component with correct values', () => { + expect(findToggle().exists()).toBe(true); + expect(findToggle().props('value')).toBe(defaultProvide.containerScanningForRegistryEnabled); + }); + + it('should disable toggle when feature is not configured', () => { + createComponent({ + propsData: { + feature: { + available: true, + configured: false, + }, + }, + }); + expect(findToggle().props('disabled')).toBe(true); + }); + + it.each([true, false])( + 'calls mutation on toggle change with correct payload', + async (enabled) => { + createComponent({ enabled }); + + findToggle().vm.$emit('change', enabled); + + expect(requestHandlers.setCVSMutationHandler).toHaveBeenCalledWith({ + input: { + projectPath: 'project/full/path', + enable: enabled, + }, + }); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(enabled); + }, + ); + + describe('when feature flag is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + containerScanningForRegistry: false, + }, + ...defaultProvide, + }, + }); + }); + + it('should not render toggle', () => { + expect(findToggle().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index f1826e0e138e30e0646c8cf7ddf318e12e57dbd0..43135743c076ed6d41b205a54f1072e78ff56ff2 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,5 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { securityFeatures } from 'jest/security_configuration/mock_data'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; @@ -13,6 +14,10 @@ import { import { manageViaMRErrorMessage } from '../constants'; import { makeFeature } from './utils'; +const MockComponent = Vue.component('MockComponent', { + render: (createElement) => createElement('span'), +}); + describe('FeatureCard component', () => { let feature; let wrapper; @@ -389,4 +394,17 @@ describe('FeatureCard component', () => { }); }); }); + + describe('when a slot component is passed', () => { + beforeEach(() => { + feature = makeFeature({ + slotComponent: MockComponent, + }); + createComponent({ feature }); + }); + + it('renders the component properly', () => { + expect(wrapper.findComponent(MockComponent).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index f2eeaca89878a8c600f27d8c318e000f214af829..f1e2933d96b507468a41caf70da18ba2786b92d9 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -1,5 +1,5 @@ import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils'; -import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; +import { SCANNER_NAMES_MAP, securityFeatures } from '~/security_configuration/constants'; describe('augmentFeatures', () => { const mockSecurityFeatures = [ @@ -12,6 +12,16 @@ describe('augmentFeatures', () => { }, ]; + const mockSecurityFeaturesWithSlot = [ + { + name: 'CONTAINER_REGISTRY', + type: 'CONTAINER_REGISTRY', + security_features: { + type: 'CONTAINER_REGISTRY', + }, + }, + ]; + const expectedMockSecurityFeatures = [ { name: 'SAST', @@ -22,6 +32,16 @@ describe('augmentFeatures', () => { }, ]; + const expectedMockSecurityWithSlotFeatures = [ + { + name: 'CONTAINER_REGISTRY', + type: 'CONTAINER_REGISTRY', + securityFeatures: { + type: 'CONTAINER_REGISTRY', + }, + }, + ]; + const expectedInvalidMockSecurityFeatures = [ { foo: 'bar', @@ -129,6 +149,10 @@ describe('augmentFeatures', () => { augmentedSecurityFeatures: expectedMockSecurityFeatures, }; + const expectedOutputWithSlot = { + augmentedSecurityFeatures: expectedMockSecurityWithSlotFeatures, + }; + const expectedInvalidOutputDefault = { augmentedSecurityFeatures: expectedInvalidMockSecurityFeatures, }; @@ -172,32 +196,48 @@ describe('augmentFeatures', () => { describe('returns an object with augmentedSecurityFeatures when', () => { it('given an properly formatted array', () => { - expect(augmentFeatures(mockSecurityFeatures)).toEqual(expectedOutputDefault); + expect(augmentFeatures(securityFeatures, mockSecurityFeatures)).toEqual( + expectedOutputDefault, + ); }); it('given an invalid populated array', () => { expect( - augmentFeatures([{ ...mockSecurityFeatures[0], ...mockInvalidCustomFeature[0] }]), + augmentFeatures(securityFeatures, [ + { ...mockSecurityFeatures[0], ...mockInvalidCustomFeature[0] }, + ]), ).toEqual(expectedInvalidOutputDefault); }); it('features have secondary key', () => { expect( - augmentFeatures([{ ...mockSecurityFeatures[0], ...mockFeaturesWithSecondary[0] }]), + augmentFeatures(securityFeatures, [ + { ...mockSecurityFeatures[0], ...mockFeaturesWithSecondary[0] }, + ]), ).toEqual(expectedOutputSecondary); }); it('given a valid populated array', () => { expect( - augmentFeatures([{ ...mockSecurityFeatures[0], ...mockValidCustomFeature[0] }]), + augmentFeatures(securityFeatures, [ + { ...mockSecurityFeatures[0], ...mockValidCustomFeature[0] }, + ]), ).toEqual(expectedOutputCustomFeature); }); + + it('when a custom vue slot is defined', () => { + expect(augmentFeatures(securityFeatures, mockSecurityFeaturesWithSlot)).toEqual( + expectedOutputWithSlot, + ); + }); }); describe('returns an object with camelcased keys', () => { it('given a customfeature in snakecase', () => { expect( - augmentFeatures([{ ...mockSecurityFeatures[0], ...mockValidCustomFeatureSnakeCase[0] }]), + augmentFeatures(securityFeatures, [ + { ...mockSecurityFeatures[0], ...mockValidCustomFeatureSnakeCase[0] }, + ]), ).toEqual(expectedOutputCustomFeature); }); }); @@ -205,7 +245,7 @@ describe('augmentFeatures', () => { describe('follows onDemandAvailable', () => { it('deletes badge when false', () => { expect( - augmentFeatures([ + augmentFeatures(securityFeatures, [ { ...mockSecurityFeaturesDast[0], ...mockValidCustomFeatureWithOnDemandAvailableFalse[0], @@ -216,7 +256,7 @@ describe('augmentFeatures', () => { it('keeps badge when true', () => { expect( - augmentFeatures([ + augmentFeatures(securityFeatures, [ { ...mockSecurityFeaturesDast[0], ...mockValidCustomFeatureWithOnDemandAvailableTrue[0] }, ]), ).toEqual(expectedOutputCustomFeatureWithOnDemandAvailableTrue);