Commit 362bbeea authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Phil Hughes

Prevent conflicting profiles in on-demand scans

This prevents an on-demand scans from being run with an invalid
combination of profiles. When an active scanner profile is selected
along with a non-validated site profile, we'll disable the form's
submission and show an alert explaining how to fix the conflict.
parent c6e7be4d
...@@ -9,6 +9,11 @@ import { ...@@ -9,6 +9,11 @@ import {
GlSprintf, GlSprintf,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import {
SCAN_TYPE_LABEL,
SCAN_TYPE,
} from 'ee/security_configuration/dast_scanner_profiles/constants';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
...@@ -21,8 +26,9 @@ import { ...@@ -21,8 +26,9 @@ import {
SITE_PROFILES_QUERY, SITE_PROFILES_QUERY,
} from '../settings'; } from '../settings';
import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql'; import dastOnDemandScanCreateMutation from '../graphql/dast_on_demand_scan_create.mutation.graphql';
import OnDemandScansScannerProfileSelector from './profile_selector/scanner_profile_selector.vue'; import ProfileSelectorSummaryCell from './profile_selector/summary_cell.vue';
import OnDemandScansSiteProfileSelector from './profile_selector/site_profile_selector.vue'; import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from './profile_selector/site_profile_selector.vue';
const createProfilesApolloOptions = (name, { fetchQuery, fetchError }) => ({ const createProfilesApolloOptions = (name, { fetchQuery, fetchError }) => ({
query: fetchQuery, query: fetchQuery,
...@@ -42,9 +48,11 @@ const createProfilesApolloOptions = (name, { fetchQuery, fetchError }) => ({ ...@@ -42,9 +48,11 @@ const createProfilesApolloOptions = (name, { fetchQuery, fetchError }) => ({
}); });
export default { export default {
SCAN_TYPE_LABEL,
components: { components: {
OnDemandScansScannerProfileSelector, ProfileSelectorSummaryCell,
OnDemandScansSiteProfileSelector, ScannerProfileSelector,
SiteProfileSelector,
GlAlert, GlAlert,
GlButton, GlButton,
GlCard, GlCard,
...@@ -88,15 +96,16 @@ export default { ...@@ -88,15 +96,16 @@ export default {
newSiteProfilePath: { newSiteProfilePath: {
default: '', default: '',
}, },
dastSiteValidationDocsPath: {
default: '',
},
}, },
data() { data() {
return { return {
scannerProfiles: [], scannerProfiles: [],
siteProfiles: [], siteProfiles: [],
form: { selectedScannerProfile: null,
[SCANNER_PROFILES_QUERY.field]: null, selectedSiteProfile: null,
[SITE_PROFILES_QUERY.field]: null,
},
loading: false, loading: false,
errorType: null, errorType: null,
errors: [], errors: [],
...@@ -114,7 +123,25 @@ export default { ...@@ -114,7 +123,25 @@ export default {
return [ERROR_FETCH_SCANNER_PROFILES, ERROR_FETCH_SITE_PROFILES].includes(this.errorType); return [ERROR_FETCH_SCANNER_PROFILES, ERROR_FETCH_SITE_PROFILES].includes(this.errorType);
}, },
someFieldEmpty() { someFieldEmpty() {
return Object.values(this.form).some(value => !value); const { selectedScannerProfile, selectedSiteProfile } = this;
return !selectedScannerProfile || !selectedSiteProfile;
},
isActiveScannerProfile() {
return this.selectedScannerProfile?.scanType === SCAN_TYPE.ACTIVE;
},
isValidatedSiteProfile() {
return this.selectedSiteProfile?.validationStatus === DAST_SITE_VALIDATION_STATUS.PASSED;
},
hasProfilesConflict() {
return (
this.glFeatures.securityOnDemandScansSiteValidation &&
!this.someFieldEmpty &&
this.isActiveScannerProfile &&
!this.isValidatedSiteProfile
);
},
isSubmitButtonDisabled() {
return this.someFieldEmpty || this.hasProfilesConflict;
}, },
}, },
methods: { methods: {
...@@ -127,7 +154,8 @@ export default { ...@@ -127,7 +154,8 @@ export default {
mutation: dastOnDemandScanCreateMutation, mutation: dastOnDemandScanCreateMutation,
variables: { variables: {
fullPath: this.projectPath, fullPath: this.projectPath,
...this.form, dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
}, },
}) })
.then(({ data: { dastOnDemandScanCreate: { pipelineUrl, errors } } }) => { .then(({ data: { dastOnDemandScanCreate: { pipelineUrl, errors } } }) => {
...@@ -209,15 +237,76 @@ export default { ...@@ -209,15 +237,76 @@ export default {
</gl-card> </gl-card>
</template> </template>
<template v-else-if="!failedToLoadProfiles"> <template v-else-if="!failedToLoadProfiles">
<on-demand-scans-scanner-profile-selector <scanner-profile-selector
v-model="form.dastScannerProfileId" v-model="selectedScannerProfile"
class="gl-mb-5" class="gl-mb-5"
:profiles="scannerProfiles" :profiles="scannerProfiles"
/> >
<on-demand-scans-site-profile-selector <template #summary="{ profile }">
v-model="form.dastSiteProfileId" <div class="row">
:profiles="siteProfiles" <profile-selector-summary-cell
/> :class="{ 'gl-text-red-500': hasProfilesConflict }"
:label="s__('DastProfiles|Scan mode')"
:value="$options.SCAN_TYPE_LABEL[profile.scanType]"
/>
</div>
<div class="row">
<profile-selector-summary-cell
:label="s__('DastProfiles|Spider timeout')"
:value="n__('%d minute', '%d minutes', profile.spiderTimeout)"
/>
<profile-selector-summary-cell
:label="s__('DastProfiles|Target timeout')"
:value="n__('%d second', '%d seconds', profile.targetTimeout)"
/>
</div>
<div class="row">
<profile-selector-summary-cell
:label="s__('DastProfiles|AJAX spider')"
:value="profile.useAjaxSpider ? __('On') : __('Off')"
/>
<profile-selector-summary-cell
:label="s__('DastProfiles|Debug messages')"
:value="
profile.showDebugMessages
? s__('DastProfiles|Show debug messages')
: s__('DastProfiles|Hide debug messages')
"
/>
</div>
</template>
</scanner-profile-selector>
<site-profile-selector v-model="selectedSiteProfile" class="gl-mb-5" :profiles="siteProfiles">
<template #summary="{ profile }">
<div class="row">
<profile-selector-summary-cell
:class="{ 'gl-text-red-500': hasProfilesConflict }"
:label="s__('DastProfiles|Target URL')"
:value="profile.targetUrl"
/>
</div>
</template>
</site-profile-selector>
<gl-alert
v-if="hasProfilesConflict"
:title="s__('OnDemandScans|You cannot run an active scan against an unvalidated site.')"
:dismissible="false"
variant="danger"
data-testid="on-demand-scans-profiles-conflict-alert"
>
<gl-sprintf
:message="
s__(
'OnDemandScans|You can either choose a passive scan or validate the target site in your chosen site profile. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}',
)
"
>
<template #docsLink="{ content }">
<gl-link :href="dastSiteValidationDocsPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<div class="gl-mt-6 gl-pt-6"> <div class="gl-mt-6 gl-pt-6">
<gl-button <gl-button
...@@ -225,7 +314,7 @@ export default { ...@@ -225,7 +314,7 @@ export default {
variant="success" variant="success"
class="js-no-auto-disable" class="js-no-auto-disable"
data-testid="on-demand-scan-submit-button" data-testid="on-demand-scan-submit-button"
:disabled="someFieldEmpty" :disabled="isSubmitButtonDisabled"
:loading="loading" :loading="loading"
> >
{{ s__('OnDemandScans|Run scan') }} {{ s__('OnDemandScans|Run scan') }}
......
...@@ -25,14 +25,14 @@ export default { ...@@ -25,14 +25,14 @@ export default {
default: () => [], default: () => [],
}, },
value: { value: {
type: String, type: Object,
required: false, required: false,
default: null, default: null,
}, },
}, },
computed: { methods: {
selectedProfile() { isChecked({ id }) {
return this.value ? this.profiles.find(({ id }) => this.value === id) : null; return this.value?.id === id;
}, },
}, },
}; };
...@@ -67,9 +67,7 @@ export default { ...@@ -67,9 +67,7 @@ export default {
</template> </template>
<gl-dropdown <gl-dropdown
:text=" :text="
selectedProfile value ? value.dropdownLabel : s__('OnDemandScans|Select one of the existing profiles')
? selectedProfile.dropdownLabel
: s__('OnDemandScans|Select one of the existing profiles')
" "
class="mw-460" class="mw-460"
data-testid="profiles-dropdown" data-testid="profiles-dropdown"
...@@ -77,19 +75,19 @@ export default { ...@@ -77,19 +75,19 @@ export default {
<gl-dropdown-item <gl-dropdown-item
v-for="profile in profiles" v-for="profile in profiles"
:key="profile.id" :key="profile.id"
:is-checked="value === profile.id" :is-checked="isChecked(profile)"
is-check-item is-check-item
@click="$emit('input', profile.id)" @click="$emit('input', profile)"
> >
{{ profile.profileName }} {{ profile.profileName }}
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
<div <div
v-if="selectedProfile && $scopedSlots.summary" v-if="value && $scopedSlots.summary"
data-testid="selected-profile-summary" data-testid="selected-profile-summary"
class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1" class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1"
> >
<slot name="summary" :profile="selectedProfile"></slot> <slot name="summary" :profile="value"></slot>
</div> </div>
</gl-form-group> </gl-form-group>
<template v-else> <template v-else>
......
<script> <script>
import { SCAN_TYPE_OPTIONS } from 'ee/security_configuration/dast_scanner_profiles/constants'; import { SCAN_TYPE_LABEL } from 'ee/security_configuration/dast_scanner_profiles/constants';
import ProfileSelector from './profile_selector.vue'; import ProfileSelector from './profile_selector.vue';
import SummaryCell from './summary_cell.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
export default { export default {
name: 'OnDemandScansScannerProfileSelector', name: 'OnDemandScansScannerProfileSelector',
components: { components: {
ProfileSelector, ProfileSelector,
SummaryCell,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
profiles: { profiles: {
type: Array, type: Array,
...@@ -25,17 +24,18 @@ export default { ...@@ -25,17 +24,18 @@ export default {
default: '', default: '',
}, },
}, },
methods: { computed: {
getScanModeText(scanType) { formattedProfiles() {
return SCAN_TYPE_OPTIONS.find(({ value }) => scanType === value)?.text; return this.profiles.map(profile => {
}, const addSuffix = str =>
getAjaxSpiderText(isEnabled) { this.glFeatures.securityOnDemandScansSiteValidation
return isEnabled ? __('On') : __('Off'); ? `${str} (${SCAN_TYPE_LABEL[profile.scanType]})`
}, : str;
getDebugMessageText(isEnabled) { return {
return isEnabled ...profile,
? s__('DastProfiles|Show debug messages') dropdownLabel: addSuffix(profile.profileName),
: s__('DastProfiles|Hide debug messages'); };
});
}, },
}, },
}; };
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
<profile-selector <profile-selector
:library-path="scannerProfilesLibraryPath" :library-path="scannerProfilesLibraryPath"
:new-profile-path="newScannerProfilePath" :new-profile-path="newScannerProfilePath"
:profiles="profiles.map(profile => ({ ...profile, dropdownLabel: profile.profileName }))" :profiles="formattedProfiles"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"
> >
...@@ -58,32 +58,7 @@ export default { ...@@ -58,32 +58,7 @@ export default {
}}</template> }}</template>
<template #new-profile>{{ s__('OnDemandScans|Create a new scanner profile') }}</template> <template #new-profile>{{ s__('OnDemandScans|Create a new scanner profile') }}</template>
<template #summary="{ profile }"> <template #summary="{ profile }">
<div class="row"> <slot name="summary" :profile="profile"></slot>
<summary-cell
:label="s__('DastProfiles|Scan mode')"
:value="getScanModeText(profile.scanType)"
/>
</div>
<div class="row">
<summary-cell
:label="s__('DastProfiles|Spider timeout')"
:value="n__('%d minute', '%d minutes', profile.spiderTimeout)"
/>
<summary-cell
:label="s__('DastProfiles|Target timeout')"
:value="n__('%d second', '%d seconds', profile.targetTimeout)"
/>
</div>
<div class="row">
<summary-cell
:label="s__('DastProfiles|AJAX spider')"
:value="getAjaxSpiderText(profile.useAjaxSpider)"
/>
<summary-cell
:label="s__('DastProfiles|Debug messages')"
:value="getDebugMessageText(profile.showDebugMessages)"
/>
</div>
</template> </template>
</profile-selector> </profile-selector>
</template> </template>
<script> <script>
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ProfileSelector from './profile_selector.vue'; import ProfileSelector from './profile_selector.vue';
import SummaryCell from './summary_cell.vue'; import { s__ } from '~/locale';
export default { export default {
name: 'OnDemandScansSiteProfileSelector', name: 'OnDemandScansSiteProfileSelector',
components: { components: {
ProfileSelector, ProfileSelector,
SummaryCell,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
profiles: { profiles: {
type: Array, type: Array,
...@@ -23,6 +25,22 @@ export default { ...@@ -23,6 +25,22 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
formattedProfiles() {
return this.profiles.map(profile => {
const isValidated = profile.validationStatus === DAST_SITE_VALIDATION_STATUS.PASSED;
const suffix = isValidated
? s__('DastProfiles|Validated')
: s__('DastProfiles|Not Validated');
const addSuffix = str =>
this.glFeatures.securityOnDemandScansSiteValidation ? `${str} (${suffix})` : str;
return {
...profile,
dropdownLabel: addSuffix(`${profile.profileName}: ${profile.targetUrl}`),
};
});
},
},
}; };
</script> </script>
...@@ -30,12 +48,7 @@ export default { ...@@ -30,12 +48,7 @@ export default {
<profile-selector <profile-selector
:library-path="siteProfilesLibraryPath" :library-path="siteProfilesLibraryPath"
:new-profile-path="newSiteProfilePath" :new-profile-path="newSiteProfilePath"
:profiles=" :profiles="formattedProfiles"
profiles.map(profile => ({
...profile,
dropdownLabel: `${profile.profileName}: ${profile.targetUrl}`,
}))
"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"
> >
...@@ -48,9 +61,7 @@ export default { ...@@ -48,9 +61,7 @@ export default {
}}</template> }}</template>
<template #new-profile>{{ s__('OnDemandScans|Create a new site profile') }}</template> <template #new-profile>{{ s__('OnDemandScans|Create a new site profile') }}</template>
<template #summary="{ profile }"> <template #summary="{ profile }">
<div class="row"> <slot name="summary" :profile="profile"></slot>
<summary-cell :label="s__('DastProfiles|Target URL')" :value="profile.targetUrl" />
</div>
</template> </template>
</profile-selector> </profile-selector>
</template> </template>
...@@ -9,7 +9,7 @@ export default () => { ...@@ -9,7 +9,7 @@ export default () => {
} }
const { const {
helpPagePath, dastSiteValidationDocsPath,
emptyStateSvgPath, emptyStateSvgPath,
projectPath, projectPath,
defaultBranch, defaultBranch,
...@@ -17,6 +17,7 @@ export default () => { ...@@ -17,6 +17,7 @@ export default () => {
siteProfilesLibraryPath, siteProfilesLibraryPath,
newSiteProfilePath, newSiteProfilePath,
newScannerProfilePath, newScannerProfilePath,
helpPagePath,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -27,6 +28,7 @@ export default () => { ...@@ -27,6 +28,7 @@ export default () => {
siteProfilesLibraryPath, siteProfilesLibraryPath,
newScannerProfilePath, newScannerProfilePath,
newSiteProfilePath, newSiteProfilePath,
dastSiteValidationDocsPath,
}, },
render(h) { render(h) {
return h(OnDemandScansApp, { return h(OnDemandScansApp, {
......
...@@ -5,13 +5,18 @@ export const SCAN_TYPE = { ...@@ -5,13 +5,18 @@ export const SCAN_TYPE = {
PASSIVE: 'PASSIVE', PASSIVE: 'PASSIVE',
}; };
export const SCAN_TYPE_LABEL = {
[SCAN_TYPE.ACTIVE]: s__('DastProfiles|Active'),
[SCAN_TYPE.PASSIVE]: s__('DastProfiles|Passive'),
};
export const SCAN_TYPE_OPTIONS = [ export const SCAN_TYPE_OPTIONS = [
{ {
value: SCAN_TYPE.ACTIVE, value: SCAN_TYPE.ACTIVE,
text: s__('DastProfiles|Active'), text: SCAN_TYPE_LABEL[SCAN_TYPE.ACTIVE],
}, },
{ {
value: SCAN_TYPE.PASSIVE, value: SCAN_TYPE.PASSIVE,
text: s__('DastProfiles|Passive'), text: SCAN_TYPE_LABEL[SCAN_TYPE.PASSIVE],
}, },
]; ];
...@@ -2,7 +2,10 @@ ...@@ -2,7 +2,10 @@
module Projects module Projects
class OnDemandScansController < Projects::ApplicationController class OnDemandScansController < Projects::ApplicationController
before_action :authorize_read_on_demand_scans! before_action do
authorize_read_on_demand_scans!
push_frontend_feature_flag(:security_on_demand_scans_site_validation, @project)
end
feature_category :dynamic_application_security_testing feature_category :dynamic_application_security_testing
......
...@@ -4,6 +4,7 @@ module Projects::OnDemandScansHelper ...@@ -4,6 +4,7 @@ module Projects::OnDemandScansHelper
def on_demand_scans_data(project) def on_demand_scans_data(project)
{ {
'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'), 'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'),
'dast-site-validation-docs-path' => help_page_path('user/application_security/dast/index', anchor: 'dast-site-validation'),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'),
'default-branch' => project.default_branch, 'default-branch' => project.default_branch,
'project-path' => project.path_with_namespace, 'project-path' => project.path_with_namespace,
......
...@@ -2,8 +2,8 @@ import { GlForm, GlSkeletonLoader } from '@gitlab/ui'; ...@@ -2,8 +2,8 @@ import { GlForm, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue'; import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import OnDemandScansScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue'; import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue';
import OnDemandScansSiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue'; import SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import dastOnDemandScanCreate from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql'; import dastOnDemandScanCreate from 'ee/on_demand_scans/graphql/dast_on_demand_scan_create.mutation.graphql';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { scannerProfiles, siteProfiles } from '../mock_data'; import { scannerProfiles, siteProfiles } from '../mock_data';
...@@ -34,6 +34,8 @@ const defaultMocks = { ...@@ -34,6 +34,8 @@ const defaultMocks = {
}; };
const pipelineUrl = `/${projectPath}/pipelines/123`; const pipelineUrl = `/${projectPath}/pipelines/123`;
const [passiveScannerProfile, activeScannerProfile] = scannerProfiles;
const [nonValidatedSiteProfile, validatedSiteProfile] = siteProfiles;
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute, isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
...@@ -46,11 +48,12 @@ describe('OnDemandScansForm', () => { ...@@ -46,11 +48,12 @@ describe('OnDemandScansForm', () => {
const findForm = () => subject.find(GlForm); const findForm = () => subject.find(GlForm);
const findByTestId = testId => subject.find(`[data-testid="${testId}"]`); const findByTestId = testId => subject.find(`[data-testid="${testId}"]`);
const findAlert = () => findByTestId('on-demand-scan-error'); const findAlert = () => findByTestId('on-demand-scan-error');
const findProfilesConflictAlert = () => findByTestId('on-demand-scans-profiles-conflict-alert');
const findSubmitButton = () => findByTestId('on-demand-scan-submit-button'); const findSubmitButton = () => findByTestId('on-demand-scan-submit-button');
const setFormData = () => { const setValidFormData = () => {
subject.find(OnDemandScansScannerProfileSelector).vm.$emit('input', scannerProfiles[0].id); subject.find(ScannerProfileSelector).vm.$emit('input', passiveScannerProfile);
subject.find(OnDemandScansSiteProfileSelector).vm.$emit('input', siteProfiles[0].id); subject.find(SiteProfileSelector).vm.$emit('input', nonValidatedSiteProfile);
return subject.vm.$nextTick(); return subject.vm.$nextTick();
}; };
const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} }); const submitForm = () => findForm().vm.$emit('submit', { preventDefault: () => {} });
...@@ -68,6 +71,9 @@ describe('OnDemandScansForm', () => { ...@@ -68,6 +71,9 @@ describe('OnDemandScansForm', () => {
siteProfilesLibraryPath, siteProfilesLibraryPath,
newScannerProfilePath, newScannerProfilePath,
newSiteProfilePath, newSiteProfilePath,
glFeatures: {
securityOnDemandScansSiteValidation: true,
},
}, },
}, },
options, options,
...@@ -134,7 +140,7 @@ describe('OnDemandScansForm', () => { ...@@ -134,7 +140,7 @@ describe('OnDemandScansForm', () => {
}); });
it('becomes enabled when form is valid', async () => { it('becomes enabled when form is valid', async () => {
await setFormData(); await setValidFormData();
expect(submitButton.props('disabled')).toBe(false); expect(submitButton.props('disabled')).toBe(false);
}); });
...@@ -155,7 +161,7 @@ describe('OnDemandScansForm', () => { ...@@ -155,7 +161,7 @@ describe('OnDemandScansForm', () => {
jest jest
.spyOn(subject.vm.$apollo, 'mutate') .spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } }); .mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl, errors: [] } } });
await setFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -167,8 +173,8 @@ describe('OnDemandScansForm', () => { ...@@ -167,8 +173,8 @@ describe('OnDemandScansForm', () => {
expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(subject.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: dastOnDemandScanCreate, mutation: dastOnDemandScanCreate,
variables: { variables: {
dastScannerProfileId: scannerProfiles[0].id, dastScannerProfileId: passiveScannerProfile.id,
dastSiteProfileId: siteProfiles[0].id, dastSiteProfileId: nonValidatedSiteProfile.id,
fullPath: projectPath, fullPath: projectPath,
}, },
}); });
...@@ -186,7 +192,7 @@ describe('OnDemandScansForm', () => { ...@@ -186,7 +192,7 @@ describe('OnDemandScansForm', () => {
describe('on top-level error', () => { describe('on top-level error', () => {
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue(); jest.spyOn(subject.vm.$apollo, 'mutate').mockRejectedValue();
await setFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -208,7 +214,7 @@ describe('OnDemandScansForm', () => { ...@@ -208,7 +214,7 @@ describe('OnDemandScansForm', () => {
jest jest
.spyOn(subject.vm.$apollo, 'mutate') .spyOn(subject.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl: null, errors } } }); .mockResolvedValue({ data: { dastOnDemandScanCreate: { pipelineUrl: null, errors } } });
await setFormData(); await setValidFormData();
submitForm(); submitForm();
}); });
...@@ -226,4 +232,52 @@ describe('OnDemandScansForm', () => { ...@@ -226,4 +232,52 @@ describe('OnDemandScansForm', () => {
}); });
}); });
}); });
describe.each`
description | selectedScannerProfile | selectedSiteProfile | hasConflict
${'a passive scan and a non-validated site'} | ${passiveScannerProfile} | ${nonValidatedSiteProfile} | ${false}
${'a passive scan and a validated site'} | ${passiveScannerProfile} | ${validatedSiteProfile} | ${false}
${'an active scan and a non-validated site'} | ${activeScannerProfile} | ${nonValidatedSiteProfile} | ${true}
${'an active scan and a validated site'} | ${activeScannerProfile} | ${validatedSiteProfile} | ${false}
`(
'profiles conflict prevention',
({ description, selectedScannerProfile, selectedSiteProfile, hasConflict }) => {
const setFormData = () => {
subject.find(ScannerProfileSelector).vm.$emit('input', selectedScannerProfile);
subject.find(SiteProfileSelector).vm.$emit('input', selectedSiteProfile);
return subject.vm.$nextTick();
};
it(
hasConflict
? `warns about conflicting profiles when user selects ${description}`
: `does not report any conflict when user selects ${description}`,
async () => {
mountShallowSubject();
await setFormData();
expect(findProfilesConflictAlert().exists()).toBe(hasConflict);
expect(findSubmitButton().props('disabled')).toBe(hasConflict);
},
);
describe('feature flag disabled', () => {
beforeEach(() => {
mountShallowSubject({
provide: {
glFeatures: {
securityOnDemandScansSiteValidation: false,
},
},
});
return setFormData();
});
it(`does not report any conflict when user selects ${description}`, () => {
expect(findProfilesConflictAlert().exists()).toBe(false);
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
},
);
}); });
...@@ -4,7 +4,7 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`] ...@@ -4,7 +4,7 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`]
<div <div
class="gl-card" class="gl-card"
data-foo="bar" data-foo="bar"
value="gid://gitlab/DastScannerProfile/1" value="[object Object]"
> >
<div <div
class="gl-card-header" class="gl-card-header"
...@@ -83,7 +83,7 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`] ...@@ -83,7 +83,7 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`]
<span <span
class="gl-new-dropdown-button-text" class="gl-new-dropdown-button-text"
> >
Scanner profile #1 Scanner profile #1 (Passive)
</span> </span>
<svg <svg
...@@ -191,126 +191,8 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`] ...@@ -191,126 +191,8 @@ exports[`OnDemandScansScannerProfileSelector renders properly with profiles 1`]
class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1" class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1"
data-testid="selected-profile-summary" data-testid="selected-profile-summary"
> >
<div <div>
class="row" Scanner profile #1's summary
>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
Scan mode:
</div>
<div
class="col-md-9"
>
<strong>
Passive
</strong>
</div>
</div>
</div>
</div>
<div
class="row"
>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
Spider timeout:
</div>
<div
class="col-md-9"
>
<strong>
5 minutes
</strong>
</div>
</div>
</div>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
Target timeout:
</div>
<div
class="col-md-9"
>
<strong>
10 seconds
</strong>
</div>
</div>
</div>
</div>
<div
class="row"
>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
AJAX spider:
</div>
<div
class="col-md-9"
>
<strong>
Off
</strong>
</div>
</div>
</div>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
Debug messages:
</div>
<div
class="col-md-9"
>
<strong>
Hide debug messages
</strong>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!----> <!---->
......
...@@ -4,7 +4,7 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = ` ...@@ -4,7 +4,7 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = `
<div <div
class="gl-card" class="gl-card"
data-foo="bar" data-foo="bar"
value="gid://gitlab/DastSiteProfile/1" value="[object Object]"
> >
<div <div
class="gl-card-header" class="gl-card-header"
...@@ -83,7 +83,7 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = ` ...@@ -83,7 +83,7 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = `
<span <span
class="gl-new-dropdown-button-text" class="gl-new-dropdown-button-text"
> >
Site profile #1: https://foo.com Site profile #1: https://foo.com (Not Validated)
</span> </span>
<svg <svg
...@@ -191,30 +191,8 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = ` ...@@ -191,30 +191,8 @@ exports[`OnDemandScansSiteProfileSelector renders properly with profiles 1`] = `
class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1" class="gl-mt-6 gl-pt-6 gl-border-t-solid gl-border-gray-100 gl-border-t-1"
data-testid="selected-profile-summary" data-testid="selected-profile-summary"
> >
<div <div>
class="row" Site profile #1's summary
>
<div
class="col-md-6"
>
<div
class="row gl-my-2"
>
<div
class="col-md-3"
>
Target URL:
</div>
<div
class="col-md-9"
>
<strong>
https://foo.com
</strong>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!----> <!---->
......
...@@ -105,7 +105,7 @@ describe('OnDemandScansProfileSelector', () => { ...@@ -105,7 +105,7 @@ describe('OnDemandScansProfileSelector', () => {
it('when a profile is selected, input event is emitted', async () => { it('when a profile is selected, input event is emitted', async () => {
await selectFirstProfile(); await selectFirstProfile();
expect(wrapper.emitted('input')).toEqual([[scannerProfiles[0].id]]); expect(wrapper.emitted('input')).toEqual([[scannerProfiles[0]]]);
}); });
it('shows dropdown items for each profile', () => { it('shows dropdown items for each profile', () => {
...@@ -130,7 +130,7 @@ describe('OnDemandScansProfileSelector', () => { ...@@ -130,7 +130,7 @@ describe('OnDemandScansProfileSelector', () => {
createFullComponent({ createFullComponent({
propsData: { propsData: {
profiles: scannerProfiles, profiles: scannerProfiles,
value: selectedProfile.id, value: selectedProfile,
}, },
}); });
}); });
......
...@@ -9,6 +9,10 @@ const TEST_NEW_PATH = '/test/new/scanner/profile/path'; ...@@ -9,6 +9,10 @@ const TEST_NEW_PATH = '/test/new/scanner/profile/path';
const TEST_ATTRS = { const TEST_ATTRS = {
'data-foo': 'bar', 'data-foo': 'bar',
}; };
const profiles = scannerProfiles.map(x => {
const suffix = x.scanType === 'ACTIVE' ? 'Active' : 'Passive';
return { ...x, dropdownLabel: `${x.profileName} (${suffix})` };
});
describe('OnDemandScansScannerProfileSelector', () => { describe('OnDemandScansScannerProfileSelector', () => {
let wrapper; let wrapper;
...@@ -25,6 +29,10 @@ describe('OnDemandScansScannerProfileSelector', () => { ...@@ -25,6 +29,10 @@ describe('OnDemandScansScannerProfileSelector', () => {
provide: { provide: {
scannerProfilesLibraryPath: TEST_LIBRARY_PATH, scannerProfilesLibraryPath: TEST_LIBRARY_PATH,
newScannerProfilePath: TEST_NEW_PATH, newScannerProfilePath: TEST_NEW_PATH,
glFeatures: { securityOnDemandScansSiteValidation: true },
},
scopedSlots: {
summary: '<div slot-scope="{ profile }">{{ profile.profileName }}\'s summary</div>',
}, },
}, },
options, options,
...@@ -42,7 +50,7 @@ describe('OnDemandScansScannerProfileSelector', () => { ...@@ -42,7 +50,7 @@ describe('OnDemandScansScannerProfileSelector', () => {
it('renders properly with profiles', () => { it('renders properly with profiles', () => {
createFullComponent({ createFullComponent({
propsData: { profiles: scannerProfiles, value: scannerProfiles[0].id }, propsData: { profiles, value: profiles[0] },
}); });
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
...@@ -67,22 +75,42 @@ describe('OnDemandScansScannerProfileSelector', () => { ...@@ -67,22 +75,42 @@ describe('OnDemandScansScannerProfileSelector', () => {
}); });
describe('with profiles', () => { describe('with profiles', () => {
beforeEach(() => { it('renders profile selector', () => {
createComponent({ createComponent({
propsData: { profiles: scannerProfiles }, propsData: { profiles },
}); });
});
it('renders profile selector', () => {
const sel = findProfileSelector(); const sel = findProfileSelector();
expect(sel.props()).toEqual({ expect(sel.props()).toEqual({
libraryPath: TEST_LIBRARY_PATH, libraryPath: TEST_LIBRARY_PATH,
newProfilePath: TEST_NEW_PATH, newProfilePath: TEST_NEW_PATH,
profiles: scannerProfiles.map(x => ({ ...x, dropdownLabel: x.profileName })), profiles,
value: null, value: null,
}); });
expect(sel.attributes()).toMatchObject(TEST_ATTRS); expect(sel.attributes()).toMatchObject(TEST_ATTRS);
}); });
describe('feature flag disabled', () => {
beforeEach(() => {
createComponent({
propsData: { profiles },
provide: {
glFeatures: { securityOnDemandScansSiteValidation: false },
},
});
});
it('renders profile selector', () => {
const sel = findProfileSelector();
expect(sel.props()).toEqual({
libraryPath: TEST_LIBRARY_PATH,
newProfilePath: TEST_NEW_PATH,
profiles: scannerProfiles.map(x => ({ ...x, dropdownLabel: `${x.profileName}` })),
value: null,
});
expect(sel.attributes()).toMatchObject(TEST_ATTRS);
});
});
}); });
}); });
...@@ -9,6 +9,13 @@ const TEST_NEW_PATH = '/test/new/site/profile/path'; ...@@ -9,6 +9,13 @@ const TEST_NEW_PATH = '/test/new/site/profile/path';
const TEST_ATTRS = { const TEST_ATTRS = {
'data-foo': 'bar', 'data-foo': 'bar',
}; };
const profiles = siteProfiles.map(x => {
const suffix = x.validationStatus === 'PASSED_VALIDATION' ? 'Validated' : 'Not Validated';
return {
...x,
dropdownLabel: `${x.profileName}: ${x.targetUrl} (${suffix})`,
};
});
describe('OnDemandScansSiteProfileSelector', () => { describe('OnDemandScansSiteProfileSelector', () => {
let wrapper; let wrapper;
...@@ -25,6 +32,10 @@ describe('OnDemandScansSiteProfileSelector', () => { ...@@ -25,6 +32,10 @@ describe('OnDemandScansSiteProfileSelector', () => {
provide: { provide: {
siteProfilesLibraryPath: TEST_LIBRARY_PATH, siteProfilesLibraryPath: TEST_LIBRARY_PATH,
newSiteProfilePath: TEST_NEW_PATH, newSiteProfilePath: TEST_NEW_PATH,
glFeatures: { securityOnDemandScansSiteValidation: true },
},
scopedSlots: {
summary: '<div slot-scope="{ profile }">{{ profile.profileName }}\'s summary</div>',
}, },
}, },
options, options,
...@@ -42,7 +53,7 @@ describe('OnDemandScansSiteProfileSelector', () => { ...@@ -42,7 +53,7 @@ describe('OnDemandScansSiteProfileSelector', () => {
it('renders properly with profiles', () => { it('renders properly with profiles', () => {
createFullComponent({ createFullComponent({
propsData: { profiles: siteProfiles, value: siteProfiles[0].id }, propsData: { profiles, value: profiles[0] },
}); });
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
...@@ -67,25 +78,45 @@ describe('OnDemandScansSiteProfileSelector', () => { ...@@ -67,25 +78,45 @@ describe('OnDemandScansSiteProfileSelector', () => {
}); });
describe('with profiles', () => { describe('with profiles', () => {
beforeEach(() => { it('renders profile selector', () => {
createComponent({ createComponent({
propsData: { profiles: siteProfiles }, propsData: { profiles },
}); });
});
it('renders profile selector', () => {
const sel = findProfileSelector(); const sel = findProfileSelector();
expect(sel.props()).toEqual({ expect(sel.props()).toEqual({
libraryPath: TEST_LIBRARY_PATH, libraryPath: TEST_LIBRARY_PATH,
newProfilePath: TEST_NEW_PATH, newProfilePath: TEST_NEW_PATH,
profiles: siteProfiles.map(x => ({ profiles,
...x,
dropdownLabel: `${x.profileName}: ${x.targetUrl}`,
})),
value: null, value: null,
}); });
expect(sel.attributes()).toMatchObject(TEST_ATTRS); expect(sel.attributes()).toMatchObject(TEST_ATTRS);
}); });
describe('feature flag disabled', () => {
beforeEach(() => {
createComponent({
propsData: { profiles },
provide: {
glFeatures: { securityOnDemandScansSiteValidation: false },
},
});
});
it('renders profile selector', () => {
const sel = findProfileSelector();
expect(sel.props()).toEqual({
libraryPath: TEST_LIBRARY_PATH,
newProfilePath: TEST_NEW_PATH,
profiles: siteProfiles.map(x => ({
...x,
dropdownLabel: `${x.profileName}: ${x.targetUrl}`,
})),
value: null,
});
expect(sel.attributes()).toMatchObject(TEST_ATTRS);
});
});
}); });
}); });
...@@ -24,10 +24,12 @@ export const siteProfiles = [ ...@@ -24,10 +24,12 @@ export const siteProfiles = [
id: 'gid://gitlab/DastSiteProfile/1', id: 'gid://gitlab/DastSiteProfile/1',
profileName: 'Site profile #1', profileName: 'Site profile #1',
targetUrl: 'https://foo.com', targetUrl: 'https://foo.com',
validationStatus: 'PENDING_VALIDATION',
}, },
{ {
id: 'gid://gitlab/DastSiteProfile/2', id: 'gid://gitlab/DastSiteProfile/2',
profileName: 'Site profile #2', profileName: 'Site profile #2',
targetUrl: 'https://bar.com', targetUrl: 'https://bar.com',
validationStatus: 'PASSED_VALIDATION',
}, },
]; ];
...@@ -9,6 +9,7 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -9,6 +9,7 @@ RSpec.describe Projects::OnDemandScansHelper do
it 'returns proper data' do it 'returns proper data' do
expect(helper.on_demand_scans_data(project)).to match( expect(helper.on_demand_scans_data(project)).to match(
'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'), 'help-page-path' => help_page_path('user/application_security/dast/index', anchor: 'on-demand-scans'),
'dast-site-validation-docs-path' => help_page_path('user/application_security/dast/index', anchor: 'dast-site-validation'),
'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'),
'default-branch' => project.default_branch, 'default-branch' => project.default_branch,
'project-path' => project.path_with_namespace, 'project-path' => project.path_with_namespace,
......
...@@ -8559,6 +8559,9 @@ msgstr "" ...@@ -8559,6 +8559,9 @@ msgstr ""
msgid "DastProfiles|No profiles created yet" msgid "DastProfiles|No profiles created yet"
msgstr "" msgstr ""
msgid "DastProfiles|Not Validated"
msgstr ""
msgid "DastProfiles|Passive" msgid "DastProfiles|Passive"
msgstr "" msgstr ""
...@@ -8625,6 +8628,9 @@ msgstr "" ...@@ -8625,6 +8628,9 @@ msgstr ""
msgid "DastProfiles|Username form field" msgid "DastProfiles|Username form field"
msgstr "" msgstr ""
msgid "DastProfiles|Validated"
msgstr ""
msgid "DastSiteValidation|Copy HTTP header to clipboard" msgid "DastSiteValidation|Copy HTTP header to clipboard"
msgstr "" msgstr ""
...@@ -19000,6 +19006,12 @@ msgstr "" ...@@ -19000,6 +19006,12 @@ msgstr ""
msgid "OnDemandScans|Use existing site profile" msgid "OnDemandScans|Use existing site profile"
msgstr "" msgstr ""
msgid "OnDemandScans|You can either choose a passive scan or validate the target site in your chosen site profile. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}"
msgstr ""
msgid "OnDemandScans|You cannot run an active scan against an unvalidated site."
msgstr ""
msgid "Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc." msgid "Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc."
msgstr "" 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