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>
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 { helpPagePath } from '~/helpers/help_page_helper';
import { serializeFormObject } from '~/lib/utils/forms';
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 dastProfileFormMixin from '../../dast_profile_form_mixin';
import tooltipIcon from '../../dast_scanner_profiles/components/tooltip_icon.vue';
......@@ -13,6 +23,7 @@ import {
REDACTED_PASSWORD,
REDACTED_REQUEST_HEADERS,
TARGET_TYPES,
SCAN_METHODS,
} from '../constants';
import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql';
......@@ -31,8 +42,10 @@ export default {
GlFormText,
GlFormTextarea,
tooltipIcon,
GlFormSelect,
GlLink,
},
mixins: [dastProfileFormMixin()],
mixins: [dastProfileFormMixin(), glFeatureFlagMixin()],
data() {
const {
name = '',
......@@ -41,6 +54,8 @@ export default {
requestHeaders = '',
auth = {},
targetType = TARGET_TYPES.WEBSITE.value,
scanMethod = null,
scanFilePath = '',
} = this.profile;
const form = {
......@@ -60,6 +75,8 @@ export default {
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 {
tokenId: null,
token: null,
targetTypesOptions: Object.values(TARGET_TYPES),
showAPIFilePath: false,
scanMethodOptions: Object.values(SCAN_METHODS),
SCAN_METHODS,
};
},
computed: {
......@@ -114,8 +134,18 @@ export default {
? s__('DastProfiles|API endpoint 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() {
return this.form.fields.excludedUrls.value
.split(EXCLUDED_URLS_SEPARATOR)
......@@ -132,6 +162,12 @@ export default {
isTargetAPI() {
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() {
return this.authSection.fields.enabled && !this.isTargetAPI;
},
......@@ -145,6 +181,8 @@ export default {
targetType,
requestHeaders,
excludedUrls,
scanMethod,
scanFilePath,
} = serializeFormObject(this.form.fields);
return {
......@@ -159,6 +197,7 @@ export default {
...(requestHeaders !== REDACTED_REQUEST_HEADERS && {
requestHeaders,
}),
...(this.shouldRenderScanMethod && { scanMethod, scanFilePath }),
};
},
},
......@@ -254,6 +293,52 @@ export default {
/>
</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">
<gl-form-group
: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_REQUEST_HEADERS = 2048;
......@@ -10,3 +10,26 @@ export const TARGET_TYPES = {
WEBSITE: { value: 'WEBSITE', text: s__('DastProfiles|Website') },
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
before_action do
authorize_read_on_demand_dast_scan!
push_frontend_feature_flag(:dast_api_scanner, @project, default_enabled: :yaml)
end
feature_category :dynamic_application_security_testing
......
......@@ -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 { TEST_HOST } from 'helpers/test_constants';
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 profilesLibraryPath = `${TEST_HOST}/${projectFullPath}/-/security/configuration/dast_scans`;
......@@ -18,6 +22,7 @@ const profileName = 'My DAST site profile';
const targetUrl = 'http://example.com';
const excludedUrls = 'https://foo.com/logout, https://foo.com/send_mail';
const requestHeaders = 'my-new-header=something';
const scanFilePath = 'test-path';
const defaultProps = {
profilesLibraryPath,
......@@ -38,6 +43,8 @@ describe('DastSiteProfileForm', () => {
const findTargetUrlInput = () => wrapper.findByTestId('target-url-input');
const findExcludedUrlsInput = () => wrapper.findByTestId('excluded-urls-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 findTargetTypeOption = () => wrapper.findByTestId('site-type-option');
......@@ -68,14 +75,15 @@ describe('DastSiteProfileForm', () => {
.filter((r) => r.attributes('value') === type)
.at(0);
radio.element.selected = true;
radio.trigger('change');
return radio.trigger('change');
};
const createComponentFactory = (mountFn = mountExtended) => (options) => {
const mountOpts = merge(
{},
{
propsData: defaultProps,
provide: { projectFullPath },
provide: { projectFullPath, glFeatures: { dastApiScanner: true } },
},
options,
);
......@@ -179,14 +187,41 @@ describe('DastSiteProfileForm', () => {
});
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(() => {
setTargetType('API');
setTargetType(TARGET_TYPES.API.value);
});
it('should hide auth section', () => {
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`
title | profile | mutationVars | mutation | mutationKind
${'New site profile'} | ${{}} | ${{ fullPath: projectFullPath }} | ${dastSiteProfileCreateMutation} | ${'dastSiteProfileCreate'}
......@@ -202,7 +237,9 @@ describe('DastSiteProfileForm', () => {
it('passes correct props to base component', async () => {
await fillForm();
await setTargetType('API');
await setTargetType(TARGET_TYPES.API.value);
await setScanMethodOption(1);
await setFieldValue(scanFilePathInput(), scanFilePath);
const baseDastProfileForm = findBaseDastProfileForm();
expect(baseDastProfileForm.props('mutation')).toBe(mutation);
......@@ -213,6 +250,8 @@ describe('DastSiteProfileForm', () => {
excludedUrls: excludedUrls.split(', '),
requestHeaders,
targetType: 'API',
scanMethod: 'HAR',
scanFilePath,
...mutationVars,
});
});
......@@ -277,4 +316,20 @@ describe('DastSiteProfileForm', () => {
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 ""
msgid "DastProfiles|Branch missing"
msgstr ""
msgid "DastProfiles|Choose a scan method"
msgstr ""
msgid "DastProfiles|Could not create the scanner profile. Please try again."
msgstr ""
......@@ -11217,6 +11220,9 @@ msgstr ""
msgid "DastProfiles|Save profile"
msgstr ""
msgid "DastProfiles|Scan method"
msgstr ""
msgid "DastProfiles|Scan mode"
msgstr ""
......@@ -11295,12 +11301,24 @@ msgstr ""
msgid "DastProfiles|Website"
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}"
msgstr ""
msgid "DastProfiles|You cannot run an active scan against an unvalidated site."
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"
msgstr ""
......@@ -17836,6 +17854,9 @@ msgstr ""
msgid "HAR file path or URL"
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}"
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