Commit 113f16a2 authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Kushal Pandya

Add support to revoke DAST site validation

This adds ability to revoke site profie
validations in DAST Configuration
parent ad576c0c
......@@ -842,6 +842,17 @@ The site is validated and an active scan can run against it.
If a validated site profile's target URL is edited, the site is no longer validated.
### Revoke a site validation
To revoke validation from a site profile:
1. From your project's home page, go to **Security & Compliance > Configuration**.
1. Select **Manage** in the **DAST Profiles** row.
1. Select **Revoke validation** beside the validated profile.
1. Select **Revoke validation**.
The site profile's validation is revoked. An active scan cannot be run against it or any other profile with the same URL.
#### Validated site profile headers
The following are code samples of how you could provide the required site profile header in your
......
......@@ -4,8 +4,11 @@ import {
DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_POLLING_INTERVAL,
DAST_SITE_VALIDATION_MODAL_ID,
DAST_SITE_VALIDATION_REVOKE_MODAL_ID,
} from 'ee/security_configuration/dast_site_validation/constants';
import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue';
import DastSiteValidationRevokeModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_revoke_modal.vue';
import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validations.query.graphql';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -13,7 +16,7 @@ import { fetchPolicies } from '~/lib/graphql';
import { updateSiteProfilesStatuses } from '../graphql/cache_utils';
import ProfilesList from './dast_profiles_list.vue';
const { NONE, PENDING, INPROGRESS, FAILED } = DAST_SITE_VALIDATION_STATUS;
const { NONE, PENDING, INPROGRESS, FAILED, PASSED } = DAST_SITE_VALIDATION_STATUS;
export default {
components: {
......@@ -21,6 +24,7 @@ export default {
GlIcon,
GlLink,
DastSiteValidationModal,
DastSiteValidationRevokeModal,
ProfilesList,
},
apollo: {
......@@ -47,14 +51,8 @@ export default {
},
},
}) {
const store = this.$apollo.getClient();
nodes.forEach(({ normalizedTargetUrl, status }) => {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status,
store,
});
this.updateSiteProfilesStatuses(normalizedTargetUrl, status);
});
},
},
......@@ -76,9 +74,13 @@ export default {
data() {
return {
validatingProfile: null,
revokeValidationProfile: null,
};
},
statuses: DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_MODAL_ID,
DAST_SITE_VALIDATION_REVOKE_MODAL_ID,
VALIDATION_STATUS: DAST_SITE_VALIDATION_STATUS,
computed: {
urlsPendingValidation() {
return this.profiles.reduce((acc, { validationStatus, normalizedTargetUrl }) => {
......@@ -90,10 +92,18 @@ export default {
},
},
methods: {
updateSiteProfilesStatuses(normalizedTargetUrl, status) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status,
store: this.$apollo.getClient(),
});
},
isPendingValidation(status) {
return [PENDING, INPROGRESS].includes(status);
},
shouldShowValidateBtn(status) {
canValidateProfile(status) {
return [NONE, FAILED].includes(status);
},
validateBtnLabel(status) {
......@@ -104,23 +114,36 @@ export default {
shouldShowValidationStatus(status) {
return this.glFeatures.securityOnDemandScansSiteValidation && status !== NONE;
},
showValidationModal() {
this.$refs['dast-site-validation-modal'].show();
hasValidationPassed(status) {
return status === PASSED;
},
showModal(modalId) {
this.$refs[modalId].show();
},
setValidatingProfile(profile) {
this.validatingProfile = profile;
this.$nextTick(() => {
this.showValidationModal();
this.showModal(DAST_SITE_VALIDATION_MODAL_ID);
});
},
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apollo.getClient(),
setRevokeValidationProfile(profile) {
this.revokeValidationProfile = profile;
this.$nextTick(() => {
this.showModal(DAST_SITE_VALIDATION_REVOKE_MODAL_ID);
});
},
similarProfilesCount(profile) {
// Ideally checking the normalized URL should be sufficient,
// but checking the validation status is necessary to avoid anomalies
// until https://gitlab.com/gitlab-org/gitlab/-/issues/300740 is resolved
return (
this.profiles.filter(
({ normalizedTargetUrl, validationStatus }) =>
normalizedTargetUrl === profile.normalizedTargetUrl &&
validationStatus === profile.validationStatus,
).length - 1
);
},
},
};
</script>
......@@ -146,22 +169,56 @@ export default {
<template #actions="{ profile }">
<gl-button
v-if="glFeatures.securityOnDemandScansSiteValidation"
:disabled="!shouldShowValidateBtn(profile.validationStatus)"
v-if="
glFeatures.securityOnDemandScansSiteValidation &&
!hasValidationPassed(profile.validationStatus)
"
:disabled="!canValidateProfile(profile.validationStatus)"
variant="info"
category="tertiary"
size="small"
@click="setValidatingProfile(profile)"
>{{ validateBtnLabel(profile.validationStatus) }}</gl-button
>
<gl-button
v-else-if="
glFeatures.securityOnDemandScansSiteValidation &&
hasValidationPassed(profile.validationStatus)
"
variant="info"
category="tertiary"
size="small"
@click="setRevokeValidationProfile(profile)"
>{{ s__('DastSiteValidation|Revoke validation') }}</gl-button
>
</template>
<dast-site-validation-modal
v-if="validatingProfile"
ref="dast-site-validation-modal"
:ref="$options.DAST_SITE_VALIDATION_MODAL_ID"
:full-path="fullPath"
:target-url="validatingProfile.targetUrl"
@primary="startValidatingProfile(validatingProfile)"
@primary="
updateSiteProfilesStatuses(
validatingProfile.normalizedTargetUrl,
$options.VALIDATION_STATUS.PENDING,
)
"
/>
<dast-site-validation-revoke-modal
v-if="revokeValidationProfile"
:ref="$options.DAST_SITE_VALIDATION_REVOKE_MODAL_ID"
:full-path="fullPath"
:target-url="revokeValidationProfile.targetUrl"
:normalized-target-url="revokeValidationProfile.normalizedTargetUrl"
:profile-count="similarProfilesCount(revokeValidationProfile)"
@revoke="
updateSiteProfilesStatuses(
revokeValidationProfile.normalizedTargetUrl,
$options.VALIDATION_STATUS.NONE,
)
"
/>
</profiles-list>
</template>
<script>
import { GlModal, GlSprintf, GlAlert, GlLink, GlIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import * as Sentry from '~/sentry/wrapper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { DAST_SITE_VALIDATION_REVOKE_MODAL_ID } from '../constants';
import dastSiteValidationRevokeMutation from '../graphql/dast_site_validation_revoke.mutation.graphql';
export default {
name: 'DastSiteValidationRevokeModal',
DAST_SITE_VALIDATION_REVOKE_MODAL_ID,
components: {
GlModal,
GlSprintf,
GlAlert,
GlLink,
GlIcon,
},
props: {
fullPath: {
type: String,
required: true,
},
targetUrl: {
type: String,
required: true,
},
normalizedTargetUrl: {
type: String,
required: true,
},
profileCount: {
type: Number,
required: true,
},
},
data() {
return {
isLoading: false,
hasErrors: false,
};
},
computed: {
modalProps() {
return {
id: DAST_SITE_VALIDATION_REVOKE_MODAL_ID,
title: s__('DastSiteValidation|Revoke validation'),
primaryProps: {
text: s__('DastSiteValidation|Revoke validation'),
attributes: [
{ loading: this.isLoading },
{ variant: 'info' },
{ category: 'primary' },
{ 'data-testid': 'revoke-validation-button' },
],
},
cancelProps: {
text: __('Cancel'),
},
};
},
docsPath() {
return helpPagePath('user/application_security/dast/index', {
anchor: 'revoke-a-site-validation',
});
},
},
methods: {
show() {
this.$refs.modal.show();
this.hasErrors = false;
},
async revoke() {
this.isLoading = true;
try {
const {
data: {
dastSiteValidationRevoke: { errors },
},
} = await this.$apollo.mutate({
mutation: dastSiteValidationRevokeMutation,
variables: {
fullPath: this.fullPath,
normalizedTargetUrl: this.normalizedTargetUrl,
},
});
if (errors?.length) {
this.onError();
return;
}
this.$refs.modal.hide();
this.$emit('revoke');
} catch (exception) {
this.onError(exception);
} finally {
this.isLoading = false;
}
},
onError(exception = null) {
if (exception !== null) {
Sentry.captureException(exception);
}
this.hasErrors = true;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalProps.id"
:action-primary="modalProps.primaryProps"
:action-cancel="modalProps.cancelProps"
v-bind="$attrs"
v-on="$listeners"
@primary.prevent="revoke"
>
<template #modal-title>
{{ modalProps.title }}
<gl-link :href="docsPath" target="_blank" class="gl-text-gray-300 gl-ml-2">
<gl-icon name="question-o" />
</gl-link>
</template>
<gl-alert v-if="hasErrors" variant="danger" class="gl-mb-4" :dismissible="false">
{{ s__('DastSiteValidation|Could not revoke validation. Please try again.') }}
</gl-alert>
<gl-sprintf
:message="s__('DastSiteValidation|You will not be able to run active scans against %{url}.')"
>
<template #url>
<strong>{{ targetUrl }}</strong>
</template>
</gl-sprintf>
<span v-if="profileCount > 0">
{{
n__(
'DastSiteValidation|This will affect %d other profile targeting the same URL.',
'DastSiteValidation|This will affect %d other profiles targeting the same URL.',
profileCount,
)
}}
</span>
</gl-modal>
</template>
......@@ -58,4 +58,6 @@ export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST';
export const DAST_SITE_VALIDATION_MODAL_ID = 'dast-site-validation-modal';
export const DAST_SITE_VALIDATION_REVOKE_MODAL_ID = 'dast-site-validation-revoke-modal';
export const DAST_SITE_VALIDATION_POLLING_INTERVAL = 3000;
mutation dastSiteValidationRevoke($fullPath: ID!, $normalizedTargetUrl: String!) {
dastSiteValidationRevoke(
input: { fullPath: $fullPath, normalizedTargetUrl: $normalizedTargetUrl }
) {
errors
}
}
---
title: Add support for revoking DAST site validations
merge_request: 53134
author:
type: added
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import { within, fireEvent } from '@testing-library/dom';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import createApolloProvider from 'helpers/mock_apollo_helper';
......@@ -7,7 +7,11 @@ import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_valida
import Component from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { updateSiteProfilesStatuses } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import {
DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_MODAL_ID,
DAST_SITE_VALIDATION_REVOKE_MODAL_ID,
} from 'ee/security_configuration/dast_site_validation/constants';
import { siteProfiles } from '../mocks/mock_data';
import * as responses from '../mocks/apollo_mock';
......@@ -130,27 +134,73 @@ describe('EE - DastSiteProfileList', () => {
});
describe.each`
status | statusEnum | label | hasValidateButton
${'no'} | ${DAST_SITE_VALIDATION_STATUS.NONE} | ${''} | ${true}
${'pending'} | ${DAST_SITE_VALIDATION_STATUS.PENDING} | ${'Validating...'} | ${false}
${'in-progress'} | ${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validating...'} | ${false}
${'passed'} | ${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validated'} | ${false}
${'failed'} | ${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed'} | ${true}
`('profile with $status validation', ({ statusEnum, label, hasValidateButton }) => {
const profile = siteProfiles.find(({ validationStatus }) => validationStatus === statusEnum);
it(`should show correct label`, () => {
status | statusEnum | statusLabel | buttonLabel | isBtnDisabled
${'no'} | ${DAST_SITE_VALIDATION_STATUS.NONE} | ${''} | ${'Validate'} | ${false}
${'pending'} | ${DAST_SITE_VALIDATION_STATUS.PENDING} | ${'Validating...'} | ${'Validate'} | ${true}
${'in-progress'} | ${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validating...'} | ${'Validate'} | ${true}
${'passed'} | ${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validated'} | ${'Revoke validation'} | ${false}
${'failed'} | ${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed'} | ${'Retry validation'} | ${false}
`(
'profile with $status validation',
({ statusEnum, statusLabel, buttonLabel, isBtnDisabled }) => {
const profile = siteProfiles.find(
({ validationStatus }) => validationStatus === statusEnum,
);
it(`should have correct status label`, () => {
const validationStatusCell = getTableRowForProfile(profile).cells[2];
expect(validationStatusCell.innerText).toContain(label);
expect(validationStatusCell.innerText).toContain(statusLabel);
});
it('show have correct button label', () => {
const actionsCell = getTableRowForProfile(profile).cells[3];
const validateButton = within(actionsCell).queryByRole('button', {
name: buttonLabel,
});
expect(validateButton).toExist();
});
it(`should ${hasValidateButton ? 'not ' : ''} disable validate button`, () => {
it(`should ${isBtnDisabled ? '' : 'not '}disable ${buttonLabel} button`, () => {
const actionsCell = getTableRowForProfile(profile).cells[3];
const validateButton = within(actionsCell).queryByRole('button', {
name: buttonLabel,
});
expect(validateButton.hasAttribute('disabled')).toBe(isBtnDisabled);
});
},
);
describe('Actions', () => {
beforeEach(() => {
wrapper.vm.showModal = jest.fn();
jest.clearAllMocks();
});
it('validate button should open correct modal', async () => {
const profile = siteProfiles.find(
({ validationStatus }) => validationStatus === DAST_SITE_VALIDATION_STATUS.NONE,
);
const actionsCell = getTableRowForProfile(profile).cells[3];
const validateButton = within(actionsCell).queryByRole('button', {
name: /validate/i,
});
await fireEvent.click(validateButton);
expect(wrapper.vm.showModal).toHaveBeenCalledWith(DAST_SITE_VALIDATION_MODAL_ID);
});
it('revoke validation button should open correct modal', async () => {
const profile = siteProfiles.find(
({ validationStatus }) => validationStatus === DAST_SITE_VALIDATION_STATUS.PASSED,
);
const actionsCell = getTableRowForProfile(profile).cells[3];
const validateButton = within(actionsCell).queryByRole('button', {
name: /validate|Retry validation/i,
name: /revoke validation/i,
});
await fireEvent.click(validateButton);
expect(validateButton.hasAttribute('disabled')).toBe(!hasValidateButton);
expect(wrapper.vm.showModal).toHaveBeenCalledWith(DAST_SITE_VALIDATION_REVOKE_MODAL_ID);
});
});
......
import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import merge from 'lodash/merge';
import VueApollo from 'vue-apollo';
import { GlAlert, GlModal } from '@gitlab/ui';
import createApolloProvider from 'helpers/mock_apollo_helper';
import dastSiteValidationRevokeMutation from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validation_revoke.mutation.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import DastSiteValidationRevokeModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_revoke_modal.vue';
import * as responses from '../mock_data/apollo_mock';
Vue.use(VueApollo);
const fullPath = 'group/project';
const targetUrl = 'https://example.com/home';
const normalizedTargetUrl = 'https://example.com:443';
const profileCount = 3;
const defaultProps = {
fullPath,
targetUrl,
normalizedTargetUrl,
profileCount,
};
const defaultRequestHandlers = {
dastSiteValidationRevoke: jest.fn().mockResolvedValue(responses.dastSiteValidationRevoke()),
};
describe('DastSiteValidationRevokeModal', () => {
let wrapper;
let requestHandlers;
const componentFactory = (mountFn = shallowMount) => ({
mountOptions = {},
handlers = {},
} = {}) => {
requestHandlers = { ...defaultRequestHandlers, ...handlers };
wrapper = mountFn(
DastSiteValidationRevokeModal,
merge(
{},
{
propsData: defaultProps,
attrs: {
static: true,
visible: true,
},
},
mountOptions,
{
apolloProvider: createApolloProvider([
[dastSiteValidationRevokeMutation, requestHandlers.dastSiteValidationRevoke],
]),
},
),
);
};
const createComponent = componentFactory();
const createFullComponent = componentFactory(mount);
const withinComponent = () => within(wrapper.find(GlModal).element);
const findByTestId = (id) => wrapper.find(`[data-testid="${id}"`);
const findRevokeButton = () => findByTestId('revoke-validation-button');
afterEach(() => {
wrapper.destroy();
});
it("calls GlModal's show method when own show method is called", () => {
const showMock = jest.fn();
createComponent({
mountOptions: {
stubs: {
GlModal: {
render: () => {},
methods: {
show: showMock,
},
},
},
},
});
wrapper.vm.show();
expect(showMock).toHaveBeenCalled();
});
describe('default state', () => {
beforeEach(() => {
createComponent();
});
it('renders no alert', () => {
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
it('renders warning message', () => {
expect(wrapper.text()).toBe('This will affect 3 other profiles targeting the same URL.');
});
});
describe('actions', () => {
describe('success', () => {
beforeEach(() => {
createFullComponent();
});
it('triggers the dastSiteValidationRevoke GraphQL mutation', () => {
findRevokeButton().trigger('click');
expect(requestHandlers.dastSiteValidationRevoke).toHaveBeenCalledWith({
fullPath,
normalizedTargetUrl,
});
});
});
describe('error', () => {
beforeEach(() => {
createFullComponent({
handlers: {
dastSiteValidationRevoke: jest
.fn()
.mockRejectedValue(new Error('GraphQL Network Error')),
},
});
});
it('renders an alert when revocation failed', async () => {
findRevokeButton().trigger('click');
await waitForPromises();
expect(wrapper.find(GlAlert).exists()).toBe(true);
expect(
withinComponent().getByText('Could not revoke validation. Please try again.'),
).not.toBe(null);
});
});
});
});
......@@ -15,3 +15,9 @@ export const dastSiteTokenCreate = ({ id = '1', token = '1', errors = [] }) => (
},
},
});
export const dastSiteValidationRevoke = (errors = []) => ({
data: {
dastSiteValidationRevoke: { errors },
},
});
......@@ -9194,6 +9194,9 @@ msgstr ""
msgid "DastSiteValidation|Could not create validation token. Please try again."
msgstr ""
msgid "DastSiteValidation|Could not revoke validation. Please try again."
msgstr ""
msgid "DastSiteValidation|Download validation text file"
msgstr ""
......@@ -9203,6 +9206,9 @@ msgstr ""
msgid "DastSiteValidation|Retry validation"
msgstr ""
msgid "DastSiteValidation|Revoke validation"
msgstr ""
msgid "DastSiteValidation|Step 1 - Choose site validation method"
msgstr ""
......@@ -9227,6 +9233,11 @@ msgstr ""
msgid "DastSiteValidation|The validation is in progress. Please wait..."
msgstr ""
msgid "DastSiteValidation|This will affect %d other profile targeting the same URL."
msgid_plural "DastSiteValidation|This will affect %d other profiles targeting the same URL."
msgstr[0] ""
msgstr[1] ""
msgid "DastSiteValidation|Validate"
msgstr ""
......@@ -9245,6 +9256,9 @@ msgstr ""
msgid "DastSiteValidation|Validation succeeded. Both active and passive scans can be run against the target site."
msgstr ""
msgid "DastSiteValidation|You will not be able to run active scans against %{url}."
msgstr ""
msgid "Data is still calculating..."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment