Move API fuzzing YAML generation to the client

This moves the API fuzzing configuration YAML snippet generation to the
client-side. This results in a faster UX and gives us more flexibility
to manipulate the snippet (by adding hints as comments for example).

Changelog: changed
EE: true
parent c9336b9c
......@@ -13,6 +13,7 @@ export const initApiFuzzingConfiguration = () => {
const {
securityConfigurationPath,
fullPath,
gitlabCiYamlEditPath,
apiFuzzingDocumentationPath,
apiFuzzingAuthenticationDocumentationPath,
ciVariablesDocumentationPath,
......@@ -26,6 +27,7 @@ export const initApiFuzzingConfiguration = () => {
provide: {
securityConfigurationPath,
fullPath,
gitlabCiYamlEditPath,
apiFuzzingDocumentationPath,
apiFuzzingAuthenticationDocumentationPath,
ciVariablesDocumentationPath,
......
......@@ -10,7 +10,6 @@ import {
GlLink,
GlSprintf,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import ConfigurationSnippetModal from 'ee/security_configuration/components/configuration_snippet_modal.vue';
import { CONFIGURATION_SNIPPET_MODAL_ID } from 'ee/security_configuration/components/constants';
import { isEmptyValue } from '~/lib/utils/forms';
......@@ -20,8 +19,7 @@ import DropdownInput from '../../components/dropdown_input.vue';
import DynamicFields from '../../components/dynamic_fields.vue';
import FormInput from '../../components/form_input.vue';
import { SCAN_MODES } from '../constants';
import apiFuzzingCiConfigurationCreate from '../graphql/api_fuzzing_ci_configuration_create.mutation.graphql';
import { insertTips } from '../utils';
import { buildConfigurationSnippet } from '../utils';
export default {
CONFIGURATION_SNIPPET_MODAL_ID,
......@@ -44,6 +42,7 @@ export default {
inject: [
'securityConfigurationPath',
'fullPath',
'gitlabCiYamlEditPath',
'apiFuzzingAuthenticationDocumentationPath',
'ciVariablesDocumentationPath',
'projectCiSettingsPath',
......@@ -57,8 +56,6 @@ export default {
},
data() {
return {
isLoading: false,
isErrorVisible: false,
targetUrl: {
field: 'targetUrl',
label: s__('APIFuzzing|Target URL'),
......@@ -119,7 +116,6 @@ export default {
}),
),
},
ciYamlEditPath: '',
configurationYaml: '',
};
},
......@@ -159,80 +155,23 @@ export default {
}
return fields.some(({ value }) => isEmptyValue(value));
},
configurationYamlWithTips() {
if (!this.configurationYaml) {
return '';
}
return insertTips(this.configurationYaml, [
{
tip: s__('APIFuzzing|Tip: Insert this part below all stages'),
// eslint-disable-next-line @gitlab/require-i18n-strings
token: 'stages:',
},
{
tip: s__('APIFuzzing|Tip: Insert this part below all include'),
// eslint-disable-next-line @gitlab/require-i18n-strings
token: 'include:',
},
{
tip: s__(
'APIFuzzing|Tip: Insert the following variables anywhere below stages and include',
),
// eslint-disable-next-line @gitlab/require-i18n-strings
token: 'variables:',
},
]);
},
},
methods: {
async onSubmit() {
this.isLoading = true;
this.dismissError();
try {
const input = {
projectPath: this.fullPath,
target: this.targetUrl.value,
scanMode: this.scanMode.value,
apiSpecificationFile: this.apiSpecificationFile.value,
scanProfile: this.scanProfile.value,
};
if (this.authenticationEnabled) {
const [authUsername, authPassword] = this.authenticationSettings;
input.authUsername = authUsername.value;
input.authPassword = authPassword.value;
}
const {
data: {
apiFuzzingCiConfigurationCreate: {
gitlabCiYamlEditPath,
configurationYaml,
errors = [],
},
},
} = await this.$apollo.mutate({
mutation: apiFuzzingCiConfigurationCreate,
variables: { input },
});
if (errors.length) {
this.showError();
} else {
this.ciYamlEditPath = gitlabCiYamlEditPath;
this.configurationYaml = configurationYaml;
this.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show();
}
} catch (e) {
this.showError();
Sentry.captureException(e);
} finally {
this.isLoading = false;
onSubmit() {
const options = {
projectPath: this.fullPath,
target: this.targetUrl.value,
scanMode: this.scanMode.value,
apiSpecificationFile: this.apiSpecificationFile.value,
scanProfile: this.scanProfile.value,
};
if (this.authenticationEnabled) {
const [authUsername, authPassword] = this.authenticationSettings;
options.authUsername = authUsername.value;
options.authPassword = authPassword.value;
}
},
showError() {
this.isErrorVisible = true;
window.scrollTo({ top: 0 });
},
dismissError() {
this.isErrorVisible = false;
this.configurationYaml = buildConfigurationSnippet(options);
this.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show();
},
},
SCAN_MODES,
......@@ -241,10 +180,6 @@ export default {
<template>
<form @submit.prevent="onSubmit">
<gl-alert v-if="isErrorVisible" variant="danger" class="gl-mb-5" @dismiss="dismissError">
{{ s__('APIFuzzing|Code snippet could not be generated. Try again later.') }}
</gl-alert>
<form-input v-model="targetUrl.value" v-bind="targetUrl" class="gl-mb-7" />
<dropdown-input v-model="scanMode.value" v-bind="scanMode" />
......@@ -312,7 +247,6 @@ export default {
<gl-button
:disabled="someFieldEmpty"
:loading="isLoading"
type="submit"
variant="confirm"
class="js-no-auto-disable"
......@@ -320,7 +254,6 @@ export default {
>{{ s__('APIFuzzing|Generate code snippet') }}</gl-button
>
<gl-button
:disabled="isLoading"
:href="securityConfigurationPath"
data-testid="api-fuzzing-configuration-cancel-button"
>{{ __('Cancel') }}</gl-button
......@@ -328,8 +261,8 @@ export default {
<configuration-snippet-modal
:ref="$options.CONFIGURATION_SNIPPET_MODAL_ID"
:ci-yaml-edit-url="ciYamlEditPath"
:yaml="configurationYamlWithTips"
:ci-yaml-edit-url="gitlabCiYamlEditPath"
:yaml="configurationYaml"
:redirect-param="$options.CODE_SNIPPET_SOURCE_API_FUZZING"
scan-type="API Fuzzing"
/>
......
......@@ -26,3 +26,32 @@ export const SCAN_MODES = {
),
},
};
export const API_FUZZING_TARGET_URL_PLACEHOLDER = '#API_FUZZING_TARGET_URL_PLACEHOLDER';
export const API_FUZZING_SCAN_MODE_PLACEHOLDER = '#API_FUZZING_SCAN_MODE_PLACEHOLDER';
export const API_FUZZING_SPECIFICATION_FILE_PATH_PLACEHOLDER =
'#API_FUZZING_SPECIFICATION_FILE_PATH_PLACEHOLDER';
export const API_FUZZING_PROFILE_PLACEHOLDER = '#API_FUZZING_PROFILE_PLACEHOLDER';
export const API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER =
'#API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER';
export const API_FUZZING_AUTH_USERNAME_VAR_PLACEHOLDER =
'#API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER';
export const API_FUZZING_YAML_CONFIGURATION_TEMPLATE = `---
# ${s__('APIFuzzing|Tip: Insert this part below all stages')}
stages:
- fuzz
# ${s__('APIFuzzing|Tip: Insert this part below all include')}
include:
- template: Security/API-Fuzzing.gitlab-ci.yml
# ${s__('APIFuzzing|Tip: Insert the following variables anywhere below stages and include')}
variables:
FUZZAPI_TARGET_URL: ${API_FUZZING_TARGET_URL_PLACEHOLDER}
FUZZAPI_${API_FUZZING_SCAN_MODE_PLACEHOLDER}: ${API_FUZZING_SPECIFICATION_FILE_PATH_PLACEHOLDER}
FUZZAPI_PROFILE: ${API_FUZZING_PROFILE_PLACEHOLDER}`;
export const API_FUZZING_YAML_CONFIGURATION_AUTH_TEMPLATE = `
FUZZAPI_HTTP_USERNAME: "${API_FUZZING_AUTH_USERNAME_VAR_PLACEHOLDER}"
FUZZAPI_HTTP_PASSWORD: "${API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER}"`;
mutation createApiFuzzingCiConfiguration($input: ApiFuzzingCiConfigurationCreateInput!) {
apiFuzzingCiConfigurationCreate(input: $input) {
configurationYaml
gitlabCiYamlEditPath
errors
}
}
/* eslint-disable @gitlab/require-i18n-strings */
import { isString } from 'lodash';
import {
API_FUZZING_TARGET_URL_PLACEHOLDER,
API_FUZZING_SCAN_MODE_PLACEHOLDER,
API_FUZZING_SPECIFICATION_FILE_PATH_PLACEHOLDER,
API_FUZZING_PROFILE_PLACEHOLDER,
API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER,
API_FUZZING_AUTH_USERNAME_VAR_PLACEHOLDER,
API_FUZZING_YAML_CONFIGURATION_TEMPLATE,
API_FUZZING_YAML_CONFIGURATION_AUTH_TEMPLATE,
} from './constants';
export const insertTip = ({ snippet, tip, token }) => {
if (!isString(snippet)) {
throw new Error('snippet must be a string');
export const buildConfigurationSnippet = ({
target,
scanMode,
apiSpecificationFile,
scanProfile,
authUsername,
authPassword,
} = {}) => {
if (!target || !scanMode || !apiSpecificationFile || !scanProfile) {
return '';
}
if (!isString(tip)) {
throw new Error('tip must be a string');
}
if (!isString(token)) {
throw new Error('token must be a string');
}
const lines = snippet.split('\n');
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].includes(token)) {
const indent = lines[i].match(/^[ \t]+/)?.[0] ?? '';
lines[i] = lines[i].replace(token, `# ${tip}\n${indent}${token}`);
break;
}
let template = API_FUZZING_YAML_CONFIGURATION_TEMPLATE.replace(
API_FUZZING_TARGET_URL_PLACEHOLDER,
target,
)
.replace(API_FUZZING_SCAN_MODE_PLACEHOLDER, scanMode)
.replace(API_FUZZING_SPECIFICATION_FILE_PATH_PLACEHOLDER, apiSpecificationFile)
.replace(API_FUZZING_PROFILE_PLACEHOLDER, scanProfile);
if (authUsername && authPassword) {
template += API_FUZZING_YAML_CONFIGURATION_AUTH_TEMPLATE.replace(
API_FUZZING_AUTH_USERNAME_VAR_PLACEHOLDER,
authUsername,
).replace(API_FUZZING_AUTH_PASSWORD_VAR_PLACEHOLDER, authPassword);
}
return lines.join('\n');
return template;
};
export const insertTips = (snippet, tips = []) =>
tips.reduce(
(snippetWithTips, { tip, token }) => insertTip({ snippet: snippetWithTips, tip, token }),
snippet,
);
......@@ -5,6 +5,7 @@ module Projects::Security::ApiFuzzingConfigurationHelper
{
security_configuration_path: project_security_configuration_path(project),
full_path: project.full_path,
gitlab_ci_yaml_edit_path: Rails.application.routes.url_helpers.project_ci_pipeline_editor_path(project),
api_fuzzing_documentation_path: help_page_path('user/application_security/api_fuzzing/index'),
api_fuzzing_authentication_documentation_path: help_page_path('user/application_security/api_fuzzing/index', anchor: 'authentication'),
ci_variables_documentation_path: help_page_path('ci/variables/index'),
......
import { GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import ConfigurationForm from 'ee/security_configuration/api_fuzzing/components/configuration_form.vue';
......@@ -10,12 +9,12 @@ import DynamicFields from 'ee/security_configuration/components/dynamic_fields.v
import FormInput from 'ee/security_configuration/components/form_input.vue';
import { stripTypenames } from 'helpers/graphql_helpers';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants';
import {
apiFuzzingConfigurationQueryResponse,
createApiFuzzingConfigurationMutationResponse,
} from '../mock_data';
import { apiFuzzingConfigurationQueryResponse } from '../mock_data';
jest.mock('ee/security_configuration/api_fuzzing/utils', () => ({
buildConfigurationSnippet: jest.fn().mockReturnValue('configuration YAML'),
}));
describe('EE - ApiFuzzingConfigurationForm', () => {
let wrapper;
......@@ -24,7 +23,6 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
apiFuzzingConfigurationQueryResponse.data.project.apiFuzzingCiConfiguration,
);
const findAlert = () => wrapper.find(GlAlert);
const findEnableAuthenticationCheckbox = () =>
wrapper.findByTestId('api-fuzzing-enable-authentication-checkbox');
const findTargetUrlInput = () => wrapper.findAll(FormInput).at(0);
......@@ -57,6 +55,7 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
{
provide: {
fullPath: 'namespace/project',
gitlabCiYamlEditPath: '/ci/editor',
securityConfigurationPath: '/security/configuration',
apiFuzzingAuthenticationDocumentationPath:
'api_fuzzing_authentication/documentation/path',
......@@ -223,80 +222,20 @@ describe('EE - ApiFuzzingConfigurationForm', () => {
expect(findSubmitButton().props('disabled')).toBe(false);
});
it('triggers the createApiFuzzingConfiguration mutation on submit and opens the modal with the correct props', async () => {
it('opens the modal with the correct props', async () => {
createWrapper();
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue(createApiFuzzingConfigurationMutationResponse);
jest.spyOn(wrapper.vm.$refs[CONFIGURATION_SNIPPET_MODAL_ID], 'show');
await setFormData();
wrapper.find('form').trigger('submit');
await waitForPromises();
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(false);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
expect(wrapper.vm.$refs[CONFIGURATION_SNIPPET_MODAL_ID].show).toHaveBeenCalled();
expect(findConfigurationSnippetModal().props()).toEqual({
ciYamlEditUrl:
createApiFuzzingConfigurationMutationResponse.data.apiFuzzingCiConfigurationCreate
.gitlabCiYamlEditPath,
yaml: `---
# Tip: Insert this part below all stages
stages:
- fuzz
# Tip: Insert this part below all include
include:
- template: template.gitlab-ci.yml
# Tip: Insert the following variables anywhere below stages and include
variables:
- FOO: bar`,
ciYamlEditUrl: '/ci/editor',
yaml: 'configuration YAML',
redirectParam: CODE_SNIPPET_SOURCE_API_FUZZING,
scanType: 'API Fuzzing',
});
});
it('shows an error on top-level error', async () => {
createWrapper({
mocks: {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
},
});
await setFormData();
expect(findAlert().exists()).toBe(false);
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 });
});
it('shows an error on error-as-data', async () => {
createWrapper({
mocks: {
$apollo: {
mutate: jest.fn().mockResolvedValue({
data: {
apiFuzzingCiConfigurationCreate: {
errors: ['error#1'],
},
},
}),
},
},
});
await setFormData();
expect(findAlert().exists()).toBe(false);
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().exists()).toBe(true);
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 });
});
});
});
......@@ -39,20 +39,3 @@ export const apiFuzzingConfigurationQueryResponse = {
},
},
};
export const createApiFuzzingConfigurationMutationResponse = {
data: {
apiFuzzingCiConfigurationCreate: {
configurationYaml: `---
stages:
- fuzz
include:
- template: template.gitlab-ci.yml
variables:
- FOO: bar`,
gitlabCiYamlEditPath: '/ci/editor',
errors: [],
__typename: 'ApiFuzzingCiConfiguration',
},
},
};
import { insertTip, insertTips } from 'ee/security_configuration/api_fuzzing/utils';
import { omit } from 'lodash';
import { buildConfigurationSnippet } from 'ee/security_configuration/api_fuzzing/utils';
const nonStringValues = [1, {}, null];
describe('buildConfigurationSnippet', () => {
const basicOptions = {
target: '/api/fuzzing/target/url',
scanMode: 'SCANMODE',
apiSpecificationFile: '/api/specification/file',
scanProfile: 'ScanProfile-1',
};
const authOptions = {
authUsername: '$USERNAME',
authPassword: '$PASSWORD',
};
describe('insertTip', () => {
describe.each(['snippet', 'tip', 'token'])('throws when %s is', (arg) => {
const validValues = {
snippet: 'snippet',
tip: 'tip',
token: 'token',
};
it.each(nonStringValues)('%s', (value) => {
expect(() => {
insertTip({ ...validValues, [arg]: value });
}).toThrowError(`${arg} must be a string`);
});
it('returns an empty string if basic options are missing', () => {
expect(buildConfigurationSnippet()).toBe('');
});
it('returns snippet as is if token can not be found', () => {
const snippet = 'some code snippet';
expect(
insertTip({
snippet,
token: 'ghost',
tip: 'a very helpful tip',
}),
).toBe(snippet);
});
it.each(Object.keys(basicOptions))(
'returns an empty string if %s option is missing',
(option) => {
const options = omit(basicOptions, option);
const tip = 'a very helpful tip';
it.each`
snippet | token | expected
${'some code snippet'} | ${'code'} | ${`some # ${tip}\ncode snippet`}
${'some code snippet'} | ${'some'} | ${`# ${tip}\nsome code snippet`}
${'some code snippet'} | ${'e'} | ${`som# ${tip}\ne code snippet`}
`('inserts the tip on the line before the first found token', ({ snippet, token, expected }) => {
expect(
insertTip({
snippet,
token,
tip,
}),
).toBe(expected);
});
expect(buildConfigurationSnippet(options)).toBe('');
},
);
it('preserves indentation', () => {
const snippet = `---
default:
artifacts:
expire_in: 30 days`;
it('returns basic configuration YAML', () => {
expect(buildConfigurationSnippet(basicOptions)).toBe(`---
# Tip: Insert this part below all stages
stages:
- fuzz
const expected = `---
default:
artifacts:
# a very helpful tip
expire_in: 30 days`;
# Tip: Insert this part below all include
include:
- template: Security/API-Fuzzing.gitlab-ci.yml
expect(
insertTip({
snippet,
token: 'expire_in:',
tip,
}),
).toBe(expected);
# Tip: Insert the following variables anywhere below stages and include
variables:
FUZZAPI_TARGET_URL: /api/fuzzing/target/url
FUZZAPI_SCANMODE: /api/specification/file
FUZZAPI_PROFILE: ScanProfile-1`);
});
});
describe('insertTips', () => {
const validTips = [
{ tip: 'Tip 1', token: 'default:' },
{ tip: 'Tip 2', token: 'artifacts:' },
{ tip: 'Tip 3', token: 'expire_in:' },
{ tip: 'Tip 4', token: 'tags:' },
];
it.each(Object.keys(authOptions))(
'does not include authentication variables if %s option is missing',
(option) => {
const options = omit({ ...basicOptions, ...authOptions }, option);
const output = buildConfigurationSnippet(options);
it.each(nonStringValues)('throws if snippet is not a string', (snippet) => {
expect(() => {
insertTips(snippet, validTips);
}).toThrowError('snippet must be a string');
});
describe.each(['tip', 'token'])('throws if %s', (prop) => {
it.each(nonStringValues)('is %s', (value) => {
expect(() => {
insertTips('some code snippet', [
{
...validTips[0],
[prop]: value,
},
]);
}).toThrowError(`${prop} must be a string`);
});
});
expect(output).toBeTruthy();
expect(output).not.toContain('FUZZAPI_HTTP_PASSWORD');
expect(output).not.toContain('FUZZAPI_HTTP_USERNAME');
},
);
it('returns snippet as is if token can not be found', () => {
const snippet = 'some code snippet';
expect(insertTips(snippet, validTips)).toBe(snippet);
});
it('adds authentication variables if both options are provided', () => {
const output = buildConfigurationSnippet({ ...basicOptions, ...authOptions });
it('returns the snippet with properly inserted tips', () => {
const snippet = `default:
artifacts:
expire_in: 30 days
tags:
- gitlab-org`;
const expected = `# Tip 1
default:
# Tip 2
artifacts:
# Tip 3
expire_in: 30 days
# Tip 4
tags:
- gitlab-org`;
expect(insertTips(snippet, validTips)).toBe(expected);
expect(output).toContain(` FUZZAPI_HTTP_USERNAME: "$USERNAME"`);
expect(output).toContain(` FUZZAPI_HTTP_PASSWORD: "$PASSWORD"`);
});
});
......@@ -6,7 +6,6 @@ import ConfigurationSnippetModal from 'ee/security_configuration/components/conf
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { redirectTo } from '~/lib/utils/url_utility';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
import { createApiFuzzingConfigurationMutationResponse } from '../api_fuzzing/mock_data';
jest.mock('clipboard', () =>
jest.fn().mockImplementation(() => ({
......@@ -24,10 +23,8 @@ jest.mock('~/lib/utils/url_utility', () => {
};
});
const {
gitlabCiYamlEditPath,
configurationYaml,
} = createApiFuzzingConfigurationMutationResponse.data.apiFuzzingCiConfigurationCreate;
const gitlabCiYamlEditPath = '/ci/editor';
const configurationYaml = 'YAML';
const redirectParam = 'foo';
describe('EE - SecurityConfigurationSnippetModal', () => {
......
......@@ -8,6 +8,7 @@ RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
let(:security_configuration_path) { project_security_configuration_path(project) }
let(:full_path) { project.full_path }
let(:gitlab_ci_yaml_edit_path) { Rails.application.routes.url_helpers.project_ci_pipeline_editor_path(project) }
let(:api_fuzzing_documentation_path) { help_page_path('user/application_security/api_fuzzing/index') }
let(:api_fuzzing_authentication_documentation_path) { help_page_path('user/application_security/api_fuzzing/index', anchor: 'authentication') }
let(:ci_variables_documentation_path) { help_page_path('ci/variables/index') }
......@@ -29,6 +30,7 @@ RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
is_expected.to eq(
security_configuration_path: security_configuration_path,
full_path: full_path,
gitlab_ci_yaml_edit_path: gitlab_ci_yaml_edit_path,
api_fuzzing_documentation_path: api_fuzzing_documentation_path,
api_fuzzing_authentication_documentation_path: api_fuzzing_authentication_documentation_path,
ci_variables_documentation_path: ci_variables_documentation_path,
......@@ -47,6 +49,7 @@ RSpec.describe Projects::Security::ApiFuzzingConfigurationHelper do
is_expected.to eq(
security_configuration_path: security_configuration_path,
full_path: full_path,
gitlab_ci_yaml_edit_path: gitlab_ci_yaml_edit_path,
api_fuzzing_documentation_path: api_fuzzing_documentation_path,
api_fuzzing_authentication_documentation_path: api_fuzzing_authentication_documentation_path,
ci_variables_documentation_path: ci_variables_documentation_path,
......
......@@ -1575,9 +1575,6 @@ msgstr ""
msgid "APIFuzzing|Choose a profile"
msgstr ""
msgid "APIFuzzing|Code snippet could not be generated. Try again later."
msgstr ""
msgid "APIFuzzing|Configure HTTP basic authentication values. Other authentication methods are supported. %{linkStart}Learn more%{linkEnd}."
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