Commit 2c5493ad authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'djadmin-revoke-validation' into 'master'

Add support for revoking DAST site validations

See merge request gitlab-org/gitlab!53134
parents 87e63f55 113f16a2
......@@ -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`, () => {
const validationStatusCell = getTableRowForProfile(profile).cells[2];
expect(validationStatusCell.innerText).toContain(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(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 ${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(`should ${hasValidateButton ? 'not ' : ''} disable validate button`, () => {
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