Commit 3d80c1f7 authored by Dheeraj Joshi's avatar Dheeraj Joshi Committed by Simon Knox

Add support for API scan methods in DAST

This allows to configure various scan methods
for API Security using DAST which includes
OpenAPI, HAR and Postman Collection.

Changes are behind a feature flag
parent dcb79f33
<script> <script>
import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormText, GlFormTextarea } from '@gitlab/ui'; import {
GlFormGroup,
GlFormInput,
GlFormRadioGroup,
GlFormText,
GlFormTextarea,
GlFormSelect,
GlLink,
} from '@gitlab/ui';
import { initFormField } from 'ee/security_configuration/utils'; import { initFormField } from 'ee/security_configuration/utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { serializeFormObject } from '~/lib/utils/forms'; import { serializeFormObject } from '~/lib/utils/forms';
import { __, s__, n__, sprintf } from '~/locale'; import { __, s__, n__, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BaseDastProfileForm from '../../components/base_dast_profile_form.vue'; import BaseDastProfileForm from '../../components/base_dast_profile_form.vue';
import dastProfileFormMixin from '../../dast_profile_form_mixin'; import dastProfileFormMixin from '../../dast_profile_form_mixin';
import tooltipIcon from '../../dast_scanner_profiles/components/tooltip_icon.vue'; import tooltipIcon from '../../dast_scanner_profiles/components/tooltip_icon.vue';
...@@ -13,6 +23,7 @@ import { ...@@ -13,6 +23,7 @@ import {
REDACTED_PASSWORD, REDACTED_PASSWORD,
REDACTED_REQUEST_HEADERS, REDACTED_REQUEST_HEADERS,
TARGET_TYPES, TARGET_TYPES,
SCAN_METHODS,
} from '../constants'; } from '../constants';
import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql'; import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql'; import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql';
...@@ -31,8 +42,10 @@ export default { ...@@ -31,8 +42,10 @@ export default {
GlFormText, GlFormText,
GlFormTextarea, GlFormTextarea,
tooltipIcon, tooltipIcon,
GlFormSelect,
GlLink,
}, },
mixins: [dastProfileFormMixin()], mixins: [dastProfileFormMixin(), glFeatureFlagMixin()],
data() { data() {
const { const {
name = '', name = '',
...@@ -41,6 +54,8 @@ export default { ...@@ -41,6 +54,8 @@ export default {
requestHeaders = '', requestHeaders = '',
auth = {}, auth = {},
targetType = TARGET_TYPES.WEBSITE.value, targetType = TARGET_TYPES.WEBSITE.value,
scanMethod = null,
scanFilePath = '',
} = this.profile; } = this.profile;
const form = { const form = {
...@@ -60,6 +75,8 @@ export default { ...@@ -60,6 +75,8 @@ export default {
skipValidation: true, skipValidation: true,
}), }),
targetType: initFormField({ value: targetType, skipValidation: true }), targetType: initFormField({ value: targetType, skipValidation: true }),
scanMethod: initFormField({ value: scanMethod, skipValidation: true }),
scanFilePath: initFormField({ value: scanFilePath, skipValidation: true }),
}, },
}; };
...@@ -70,6 +87,9 @@ export default { ...@@ -70,6 +87,9 @@ export default {
tokenId: null, tokenId: null,
token: null, token: null,
targetTypesOptions: Object.values(TARGET_TYPES), targetTypesOptions: Object.values(TARGET_TYPES),
showAPIFilePath: false,
scanMethodOptions: Object.values(SCAN_METHODS),
SCAN_METHODS,
}; };
}, },
computed: { computed: {
...@@ -114,8 +134,18 @@ export default { ...@@ -114,8 +134,18 @@ export default {
? s__('DastProfiles|API endpoint URL') ? s__('DastProfiles|API endpoint URL')
: s__('DastProfiles|Target URL'), : s__('DastProfiles|Target URL'),
}, },
scanMethod: {
label: s__('DastProfiles|Scan method'),
helpText: s__('DastProfiles|What does each method do?'),
defaultOption: s__('DastProfiles|Choose a scan method'),
},
}; };
}, },
dastApiDocsPath() {
return helpPagePath('user/application_security/dast_api/', {
anchor: 'enable-dast-api-scanning',
});
},
parsedExcludedUrls() { parsedExcludedUrls() {
return this.form.fields.excludedUrls.value return this.form.fields.excludedUrls.value
.split(EXCLUDED_URLS_SEPARATOR) .split(EXCLUDED_URLS_SEPARATOR)
...@@ -132,6 +162,12 @@ export default { ...@@ -132,6 +162,12 @@ export default {
isTargetAPI() { isTargetAPI() {
return this.form.fields.targetType.value === TARGET_TYPES.API.value; return this.form.fields.targetType.value === TARGET_TYPES.API.value;
}, },
shouldRenderScanMethod() {
return this.glFeatures.dastApiScanner && this.isTargetAPI;
},
selectedScanMethod() {
return SCAN_METHODS[this.form.fields.scanMethod.value];
},
isAuthEnabled() { isAuthEnabled() {
return this.authSection.fields.enabled && !this.isTargetAPI; return this.authSection.fields.enabled && !this.isTargetAPI;
}, },
...@@ -145,6 +181,8 @@ export default { ...@@ -145,6 +181,8 @@ export default {
targetType, targetType,
requestHeaders, requestHeaders,
excludedUrls, excludedUrls,
scanMethod,
scanFilePath,
} = serializeFormObject(this.form.fields); } = serializeFormObject(this.form.fields);
return { return {
...@@ -159,6 +197,7 @@ export default { ...@@ -159,6 +197,7 @@ export default {
...(requestHeaders !== REDACTED_REQUEST_HEADERS && { ...(requestHeaders !== REDACTED_REQUEST_HEADERS && {
requestHeaders, requestHeaders,
}), }),
...(this.shouldRenderScanMethod && { scanMethod, scanFilePath }),
}; };
}, },
}, },
...@@ -254,6 +293,52 @@ export default { ...@@ -254,6 +293,52 @@ export default {
/> />
</gl-form-group> </gl-form-group>
<gl-form-group
v-if="shouldRenderScanMethod"
id="scan-method-popover-container"
:label="i18n.scanMethod.label"
>
<gl-form-select
v-model="form.fields.scanMethod.value"
v-validation:[form.showValidation]
:options="scanMethodOptions"
name="scanMethod"
class="mw-460"
data-testid="scan-method-select-input"
:state="form.fields.scanMethod.state"
required
>
<template #first>
<option :value="null" disabled>{{ i18n.scanMethod.defaultOption }}</option>
</template>
</gl-form-select>
<gl-form-text
><gl-link :href="dastApiDocsPath" target="_blank">{{
i18n.scanMethod.helpText
}}</gl-link></gl-form-text
>
<gl-form-group
v-if="selectedScanMethod"
class="gl-mt-5"
:label="selectedScanMethod.inputLabel"
:invalid-feedback="form.fields.scanFilePath.feedback"
>
<gl-form-input
v-model="form.fields.scanFilePath.value"
v-validation:[form.showValidation]
name="scanFilePath"
class="mw-460"
data-testid="scan-file-path-input"
type="text"
:placeholder="selectedScanMethod.placeholder"
required
:state="form.fields.scanFilePath.state"
/>
</gl-form-group>
</gl-form-group>
<div class="row"> <div class="row">
<gl-form-group <gl-form-group
:label="i18n.excludedUrls.label" :label="i18n.excludedUrls.label"
......
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
export const MAX_CHAR_LIMIT_EXCLUDED_URLS = 2048; export const MAX_CHAR_LIMIT_EXCLUDED_URLS = 2048;
export const MAX_CHAR_LIMIT_REQUEST_HEADERS = 2048; export const MAX_CHAR_LIMIT_REQUEST_HEADERS = 2048;
...@@ -10,3 +10,26 @@ export const TARGET_TYPES = { ...@@ -10,3 +10,26 @@ export const TARGET_TYPES = {
WEBSITE: { value: 'WEBSITE', text: s__('DastProfiles|Website') }, WEBSITE: { value: 'WEBSITE', text: s__('DastProfiles|Website') },
API: { value: 'API', text: s__('DastProfiles|API') }, API: { value: 'API', text: s__('DastProfiles|API') },
}; };
export const SCAN_METHODS = {
HAR: {
text: __('HTTP Archive (HAR)'),
value: 'HAR',
inputLabel: __('HAR file path or URL'),
placeholder: s__(
'DastProfiles|folder/dast_example.har or https://example.com/dast_example.har',
),
},
OPENAPI: {
text: __('OpenAPI'),
value: 'OPENAPI',
inputLabel: __('OpenAPI Specification file path or URL'),
placeholder: s__('DastProfiles|folder/openapi.json or https://example.com/openapi.json'),
},
POSTMAN_COLLECTION: {
text: __('Postman collection'),
value: 'POSTMAN_COLLECTION',
inputLabel: __('Postman collection file path or URL'),
placeholder: s__('DastProfiles|folder/example.postman_collection.json or https://example.com/'),
},
};
...@@ -8,6 +8,7 @@ module Projects ...@@ -8,6 +8,7 @@ module Projects
before_action do before_action do
authorize_read_on_demand_dast_scan! authorize_read_on_demand_dast_scan!
push_frontend_feature_flag(:dast_api_scanner, @project, default_enabled: :yaml)
end end
feature_category :dynamic_application_security_testing feature_category :dynamic_application_security_testing
......
...@@ -10,6 +10,10 @@ import dastSiteProfileUpdateMutation from 'ee/security_configuration/dast_profil ...@@ -10,6 +10,10 @@ import dastSiteProfileUpdateMutation from 'ee/security_configuration/dast_profil
import { policySiteProfiles } from 'ee_jest/security_configuration/dast_profiles/mocks/mock_data'; import { policySiteProfiles } from 'ee_jest/security_configuration/dast_profiles/mocks/mock_data';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
SCAN_METHODS,
TARGET_TYPES,
} from 'ee/security_configuration/dast_profiles/dast_site_profiles/constants';
const projectFullPath = 'group/project'; const projectFullPath = 'group/project';
const profilesLibraryPath = `${TEST_HOST}/${projectFullPath}/-/security/configuration/dast_scans`; const profilesLibraryPath = `${TEST_HOST}/${projectFullPath}/-/security/configuration/dast_scans`;
...@@ -18,6 +22,7 @@ const profileName = 'My DAST site profile'; ...@@ -18,6 +22,7 @@ const profileName = 'My DAST site profile';
const targetUrl = 'http://example.com'; const targetUrl = 'http://example.com';
const excludedUrls = 'https://foo.com/logout, https://foo.com/send_mail'; const excludedUrls = 'https://foo.com/logout, https://foo.com/send_mail';
const requestHeaders = 'my-new-header=something'; const requestHeaders = 'my-new-header=something';
const scanFilePath = 'test-path';
const defaultProps = { const defaultProps = {
profilesLibraryPath, profilesLibraryPath,
...@@ -38,6 +43,8 @@ describe('DastSiteProfileForm', () => { ...@@ -38,6 +43,8 @@ describe('DastSiteProfileForm', () => {
const findTargetUrlInput = () => wrapper.findByTestId('target-url-input'); const findTargetUrlInput = () => wrapper.findByTestId('target-url-input');
const findExcludedUrlsInput = () => wrapper.findByTestId('excluded-urls-input'); const findExcludedUrlsInput = () => wrapper.findByTestId('excluded-urls-input');
const findRequestHeadersInput = () => wrapper.findByTestId('request-headers-input'); const findRequestHeadersInput = () => wrapper.findByTestId('request-headers-input');
const findScanMethodInput = () => wrapper.findByTestId('scan-method-select-input');
const scanFilePathInput = () => wrapper.findByTestId('scan-file-path-input');
const findAuthCheckbox = () => wrapper.findByTestId('auth-enable-checkbox'); const findAuthCheckbox = () => wrapper.findByTestId('auth-enable-checkbox');
const findTargetTypeOption = () => wrapper.findByTestId('site-type-option'); const findTargetTypeOption = () => wrapper.findByTestId('site-type-option');
...@@ -68,14 +75,15 @@ describe('DastSiteProfileForm', () => { ...@@ -68,14 +75,15 @@ describe('DastSiteProfileForm', () => {
.filter((r) => r.attributes('value') === type) .filter((r) => r.attributes('value') === type)
.at(0); .at(0);
radio.element.selected = true; radio.element.selected = true;
radio.trigger('change'); return radio.trigger('change');
}; };
const createComponentFactory = (mountFn = mountExtended) => (options) => { const createComponentFactory = (mountFn = mountExtended) => (options) => {
const mountOpts = merge( const mountOpts = merge(
{}, {},
{ {
propsData: defaultProps, propsData: defaultProps,
provide: { projectFullPath }, provide: { projectFullPath, glFeatures: { dastApiScanner: true } },
}, },
options, options,
); );
...@@ -179,14 +187,41 @@ describe('DastSiteProfileForm', () => { ...@@ -179,14 +187,41 @@ describe('DastSiteProfileForm', () => {
}); });
describe('when target type is API', () => { describe('when target type is API', () => {
const getScanMethodOption = (index) => {
return findScanMethodInput().findAll('option').at(index);
};
const setScanMethodOption = (index) => {
getScanMethodOption(index).setSelected();
findScanMethodInput().trigger('change');
};
beforeEach(() => { beforeEach(() => {
setTargetType('API'); setTargetType(TARGET_TYPES.API.value);
}); });
it('should hide auth section', () => { it('should hide auth section', () => {
expect(findAuthSection().exists()).toBe(false); expect(findAuthSection().exists()).toBe(false);
}); });
describe('scan method option', () => {
it('should render all scan method options', () => {
expect(findScanMethodInput().exists()).toBe(true);
expect(getScanMethodOption(0).attributes('disabled')).toBe('disabled');
Object.values(SCAN_METHODS).forEach((method, index) => {
expect(getScanMethodOption(index + 1).text()).toBe(method.text);
});
});
it('should not show scan file-path input by default', () => {
expect(scanFilePathInput().exists()).toBe(false);
});
it('should show scan file-path input upon selection', async () => {
await setScanMethodOption(1);
expect(scanFilePathInput().exists()).toBe(true);
});
});
describe.each` describe.each`
title | profile | mutationVars | mutation | mutationKind title | profile | mutationVars | mutation | mutationKind
${'New site profile'} | ${{}} | ${{ fullPath: projectFullPath }} | ${dastSiteProfileCreateMutation} | ${'dastSiteProfileCreate'} ${'New site profile'} | ${{}} | ${{ fullPath: projectFullPath }} | ${dastSiteProfileCreateMutation} | ${'dastSiteProfileCreate'}
...@@ -202,7 +237,9 @@ describe('DastSiteProfileForm', () => { ...@@ -202,7 +237,9 @@ describe('DastSiteProfileForm', () => {
it('passes correct props to base component', async () => { it('passes correct props to base component', async () => {
await fillForm(); await fillForm();
await setTargetType('API'); await setTargetType(TARGET_TYPES.API.value);
await setScanMethodOption(1);
await setFieldValue(scanFilePathInput(), scanFilePath);
const baseDastProfileForm = findBaseDastProfileForm(); const baseDastProfileForm = findBaseDastProfileForm();
expect(baseDastProfileForm.props('mutation')).toBe(mutation); expect(baseDastProfileForm.props('mutation')).toBe(mutation);
...@@ -213,6 +250,8 @@ describe('DastSiteProfileForm', () => { ...@@ -213,6 +250,8 @@ describe('DastSiteProfileForm', () => {
excludedUrls: excludedUrls.split(', '), excludedUrls: excludedUrls.split(', '),
requestHeaders, requestHeaders,
targetType: 'API', targetType: 'API',
scanMethod: 'HAR',
scanFilePath,
...mutationVars, ...mutationVars,
}); });
}); });
...@@ -277,4 +316,20 @@ describe('DastSiteProfileForm', () => { ...@@ -277,4 +316,20 @@ describe('DastSiteProfileForm', () => {
expect(findParentFormGroup().attributes('disabled')).toBe('true'); expect(findParentFormGroup().attributes('disabled')).toBe('true');
}); });
}); });
describe('when dastApiScanner FF is disabled', () => {
beforeEach(() => {
createShallowComponent({
propsData: {
profile: policySiteProfiles[0],
},
provide: { glFeatures: { dastApiScanner: false } },
});
});
it('should not show scan method options', () => {
expect(findScanMethodInput().exists()).toBe(false);
expect(scanFilePathInput().exists()).toBe(false);
});
});
}); });
...@@ -11076,6 +11076,9 @@ msgstr "" ...@@ -11076,6 +11076,9 @@ msgstr ""
msgid "DastProfiles|Branch missing" msgid "DastProfiles|Branch missing"
msgstr "" msgstr ""
msgid "DastProfiles|Choose a scan method"
msgstr ""
msgid "DastProfiles|Could not create the scanner profile. Please try again." msgid "DastProfiles|Could not create the scanner profile. Please try again."
msgstr "" msgstr ""
...@@ -11217,6 +11220,9 @@ msgstr "" ...@@ -11217,6 +11220,9 @@ msgstr ""
msgid "DastProfiles|Save profile" msgid "DastProfiles|Save profile"
msgstr "" msgstr ""
msgid "DastProfiles|Scan method"
msgstr ""
msgid "DastProfiles|Scan mode" msgid "DastProfiles|Scan mode"
msgstr "" msgstr ""
...@@ -11295,12 +11301,24 @@ msgstr "" ...@@ -11295,12 +11301,24 @@ msgstr ""
msgid "DastProfiles|Website" msgid "DastProfiles|Website"
msgstr "" msgstr ""
msgid "DastProfiles|What does each method do?"
msgstr ""
msgid "DastProfiles|You can either choose a passive scan or validate the target site from the site profile management page. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}" msgid "DastProfiles|You can either choose a passive scan or validate the target site from the site profile management page. %{docsLinkStart}Learn more about site validation.%{docsLinkEnd}"
msgstr "" msgstr ""
msgid "DastProfiles|You cannot run an active scan against an unvalidated site." msgid "DastProfiles|You cannot run an active scan against an unvalidated site."
msgstr "" msgstr ""
msgid "DastProfiles|folder/dast_example.har or https://example.com/dast_example.har"
msgstr ""
msgid "DastProfiles|folder/example.postman_collection.json or https://example.com/"
msgstr ""
msgid "DastProfiles|folder/openapi.json or https://example.com/openapi.json"
msgstr ""
msgid "DastSiteValidation|Copy HTTP header to clipboard" msgid "DastSiteValidation|Copy HTTP header to clipboard"
msgstr "" msgstr ""
...@@ -17836,6 +17854,9 @@ msgstr "" ...@@ -17836,6 +17854,9 @@ msgstr ""
msgid "HAR file path or URL" msgid "HAR file path or URL"
msgstr "" msgstr ""
msgid "HTTP Archive (HAR)"
msgstr ""
msgid "HTTP Basic: Access denied\\nYou must use a personal access token with 'api' scope for Git over HTTP.\\nYou can generate one at %{profile_personal_access_tokens_url}" msgid "HTTP Basic: Access denied\\nYou must use a personal access token with 'api' scope for Git over HTTP.\\nYou can generate one at %{profile_personal_access_tokens_url}"
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