Commit fc8a032e authored by Mark Florian's avatar Mark Florian Committed by Natalia Tepluhina

Implement analyzers section in SAST Configuration

This integrates various pieces of ground work:
 - [Migrating a POST request to a GraphQL mutation][gql]
 - [Creating an expandable container component][exp]
 - [Creating an AnalyzerConfiguration component][ac1]
 - [Extending the AnalyzerConfiguration component][ac2]

This is implemented behind the `sast_configuration_ui_analyzers` feature
flag, since the necessary backend changes are not yet in place. A future
iteration will enable or remove the feature flag.

Other changes include:
 - Explicitly pass the disabled prop to child fields, so they can hide
   the custom value message when disabled
 - Relax the analyzer entity prop validator, since the description is
   nullable, and the template already handles this

Addresses https://gitlab.com/gitlab-org/gitlab/-/issues/238602, part of
https://gitlab.com/groups/gitlab-org/-/epics/3635.

[gql]: https://gitlab.com/gitlab-org/gitlab/-/issues/227575
[exp]: https://gitlab.com/gitlab-org/gitlab/-/issues/233521
[ac1]: https://gitlab.com/gitlab-org/gitlab/-/issues/238600
[ac2]: https://gitlab.com/gitlab-org/gitlab/-/issues/238603
parent 58f318e0
<script>
import { GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DynamicFields from './dynamic_fields.vue';
import { isValidAnalyzerEntity } from './utils';
......@@ -9,6 +10,7 @@ export default {
GlFormCheckbox,
DynamicFields,
},
mixins: [glFeatureFlagsMixin()],
model: {
prop: 'entity',
event: 'input',
......@@ -22,8 +24,11 @@ export default {
},
},
computed: {
hasConfiguration() {
return this.entity.configuration?.length > 0;
variables() {
return this.entity.variables?.nodes ?? [];
},
hasVariables() {
return this.variables.length > 0;
},
},
methods: {
......@@ -31,8 +36,8 @@ export default {
const entity = { ...this.entity, enabled };
this.$emit('input', entity);
},
onConfigurationUpdate(configuration) {
const entity = { ...this.entity, configuration };
onVariablesUpdate(variables) {
const entity = { ...this.entity, variables: { nodes: variables } };
this.$emit('input', entity);
},
},
......@@ -47,11 +52,11 @@ export default {
</gl-form-checkbox>
<dynamic-fields
v-if="hasConfiguration"
v-if="hasVariables"
:disabled="!entity.enabled"
class="gl-ml-6"
:entities="entity.configuration"
@input="onConfigurationUpdate"
class="gl-ml-6 gl-mb-0"
:entities="variables"
@input="onVariablesUpdate"
/>
</gl-form-group>
</template>
<script>
import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import sastCiConfigurationQuery from '../graphql/sast_ci_configuration.query.graphql';
import sastCiConfigurationWithAnalyzersQuery from '../graphql/sast_ci_configuration_with_analyzers.query.graphql';
import ConfigurationForm from './configuration_form.vue';
export default {
......@@ -12,6 +14,7 @@ export default {
GlLoadingIcon,
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
inject: {
sastDocumentationPath: {
from: 'sastDocumentationPath',
......@@ -24,7 +27,11 @@ export default {
},
apollo: {
sastCiConfiguration: {
query: sastCiConfigurationQuery,
query() {
return this.glFeatures.sastConfigurationUiAnalyzers
? sastCiConfigurationWithAnalyzersQuery
: sastCiConfigurationQuery;
},
variables() {
return {
fullPath: this.projectPath,
......
<script>
import { GlAlert, GlButton } from '@gitlab/ui';
import { GlAlert, GlButton, GlIcon, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AnalyzerConfiguration from './analyzer_configuration.vue';
import DynamicFields from './dynamic_fields.vue';
import ExpandableSection from './expandable_section.vue';
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
import { toSastCiConfigurationEntityInput } from './utils';
import {
toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from './utils';
export default {
components: {
AnalyzerConfiguration,
DynamicFields,
ExpandableSection,
GlAlert,
GlButton,
GlIcon,
GlLink,
},
mixins: [glFeatureFlagsMixin()],
inject: {
createSastMergeRequestPath: {
from: 'createSastMergeRequestPath',
default: '',
},
sastAnalyzersDocumentationPath: {
from: 'sastAnalyzersDocumentationPath',
default: '',
},
securityConfigurationPath: {
from: 'securityConfigurationPath',
default: '',
......@@ -39,10 +54,20 @@ export default {
return {
globalConfiguration: cloneDeep(this.sastCiConfiguration.global.nodes),
pipelineConfiguration: cloneDeep(this.sastCiConfiguration.pipeline.nodes),
analyzersConfiguration: this.glFeatures.sastConfigurationUiAnalyzers
? cloneDeep(this.sastCiConfiguration.analyzers.nodes)
: [],
hasSubmissionError: false,
isSubmitting: false,
};
},
computed: {
shouldRenderAnalyzersSection() {
return Boolean(
this.glFeatures.sastConfigurationUiAnalyzers && this.analyzersConfiguration.length > 0,
);
},
},
methods: {
onSubmit() {
this.isSubmitting = true;
......@@ -75,10 +100,26 @@ export default {
});
},
getMutationConfiguration() {
return {
const configuration = {
global: this.globalConfiguration.map(toSastCiConfigurationEntityInput),
pipeline: this.pipelineConfiguration.map(toSastCiConfigurationEntityInput),
};
if (this.glFeatures.sastConfigurationUiAnalyzers) {
configuration.analyzers = this.analyzersConfiguration.map(
toSastCiConfigurationAnalyzerEntityInput,
);
}
return configuration;
},
onAnalyzerChange(name, updatedAnalyzer) {
const index = this.analyzersConfiguration.findIndex(analyzer => analyzer.name === name);
if (index === -1) {
return;
}
this.analyzersConfiguration.splice(index, 1, updatedAnalyzer);
},
},
i18n: {
......@@ -87,6 +128,13 @@ export default {
),
submitButton: s__('SecurityConfiguration|Create Merge Request'),
cancelButton: __('Cancel'),
help: __('Help'),
analyzersHeading: s__('SecurityConfiguration|SAST Analyzers'),
analyzersSubHeading: s__(
`SecurityConfiguration|By default, all analyzers are applied in order to
cover all languages across your project, and only run if the language is
detected in the Merge Request.`,
),
},
};
</script>
......@@ -96,7 +144,35 @@ export default {
<dynamic-fields v-model="globalConfiguration" class="gl-m-0" />
<dynamic-fields v-model="pipelineConfiguration" class="gl-m-0" />
<hr />
<expandable-section
v-if="shouldRenderAnalyzersSection"
class="gl-mb-5"
data-testid="analyzers-section"
>
<template #heading>
{{ $options.i18n.analyzersHeading }}
<gl-link
target="_blank"
:href="sastAnalyzersDocumentationPath"
:aria-label="$options.i18n.help"
>
<gl-icon name="question" />
</gl-link>
</template>
<template #subheading>
{{ $options.i18n.analyzersSubHeading }}
</template>
<analyzer-configuration
v-for="analyzer in analyzersConfiguration"
:key="analyzer.name"
:entity="analyzer"
@input="onAnalyzerChange(analyzer.name, $event)"
/>
</expandable-section>
<hr v-else />
<gl-alert v-if="hasSubmissionError" class="gl-mb-5" variant="danger" :dismissible="false">{{
$options.i18n.submissionError
......
......@@ -60,6 +60,7 @@ export default {
v-for="entity in entities"
ref="fields"
:key="entity.field"
:disabled="disabled"
v-bind="entity"
@input="onInput(entity.field, $event)"
/>
......
......@@ -45,10 +45,15 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isCustomValue() {
return this.value !== this.defaultValue;
showCustomValueMessage() {
return !this.disabled && this.value !== this.defaultValue;
},
inputSize() {
return SCHEMA_TO_PROP_SIZE_MAP[this.size];
......@@ -72,9 +77,15 @@ export default {
<gl-form-text class="gl-mt-3">{{ description }}</gl-form-text>
</template>
<gl-form-input :id="field" :size="inputSize" :value="value" @input="$emit('input', $event)" />
<gl-form-input
:id="field"
:size="inputSize"
:value="value"
:disabled="disabled"
@input="$emit('input', $event)"
/>
<template v-if="isCustomValue" #description>
<template v-if="showCustomValueMessage" #description>
<gl-sprintf :message="$options.i18n.CUSTOM_VALUE_MESSAGE">
<template #anchor="{ content }">
<gl-link @click="resetToDefaultValue" v-text="content" />
......
......@@ -23,9 +23,9 @@ export const isValidAnalyzerEntity = object => {
return false;
}
const { name, label, description, enabled } = object;
const { name, label, enabled } = object;
return isString(name) && isString(label) && isString(description) && isBoolean(enabled);
return isString(name) && isString(label) && isBoolean(enabled);
};
/**
......@@ -39,3 +39,20 @@ export const toSastCiConfigurationEntityInput = ({ field, defaultValue, value })
defaultValue,
value,
});
/**
* Given a SastCiConfigurationAnalyzersEntity, returns
* a SastCiConfigurationAnalyzerEntityInput suitable for use in the
* configureSast GraphQL mutation.
* @param {SastCiConfigurationAnalyzersEntity}
* @returns {SastCiConfigurationAnalyzerEntityInput}
*/
export const toSastCiConfigurationAnalyzerEntityInput = ({ name, enabled, variables }) => {
const entity = { name, enabled };
if (enabled && variables) {
entity.variables = variables.nodes.map(toSastCiConfigurationEntityInput);
}
return entity;
};
#import "./sast_ci_configuration_entity.fragment.graphql"
fragment SastCiConfigurationFragment on SastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
#import "./sast_ci_configuration_entity.fragment.graphql"
#import "./sast_ci_configuration.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
global {
nodes {
...SastCiConfigurationEntityFragment
}
}
pipeline {
nodes {
...SastCiConfigurationEntityFragment
}
}
...SastCiConfigurationFragment
}
}
}
#import "./sast_ci_configuration.fragment.graphql"
#import "./sast_ci_configuration_entity.fragment.graphql"
query sastCiConfiguration($fullPath: ID!) {
project(fullPath: $fullPath) {
sastCiConfiguration {
...SastCiConfigurationFragment
analyzers {
nodes {
description
enabled
label
name
variables {
nodes {
...SastCiConfigurationEntityFragment
}
}
}
}
}
}
}
......@@ -20,6 +20,7 @@ export default function init() {
securityConfigurationPath,
createSastMergeRequestPath,
projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath,
} = el.dataset;
......@@ -30,6 +31,7 @@ export default function init() {
securityConfigurationPath,
createSastMergeRequestPath,
projectPath,
sastAnalyzersDocumentationPath,
sastDocumentationPath,
},
render(createElement) {
......
......@@ -11,6 +11,10 @@ module Projects
before_action :ensure_sast_configuration_enabled!, except: [:create]
before_action :authorize_edit_tree!, only: [:create]
before_action only: [:show] do
push_frontend_feature_flag(:sast_configuration_ui_analyzers, project)
end
def show
end
......
......@@ -5,6 +5,7 @@ module Projects::Security::SastConfigurationHelper
{
create_sast_merge_request_path: project_security_configuration_sast_path(project),
project_path: project.full_path,
sast_analyzers_documentation_path: help_page_path('user/application_security/sast/analyzers'),
sast_documentation_path: help_page_path('user/application_security/sast/index', anchor: 'configuration'),
security_configuration_path: project_security_configuration_path(project)
}
......
---
name: sast_configuration_ui_analyzers
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42214
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238602
group: group::static analysis
type: development
default_enabled: false
import { mount } from '@vue/test-utils';
import AnalyzerConfiguration from 'ee/security_configuration/sast/components/analyzer_configuration.vue';
import DynamicFields from 'ee/security_configuration/sast/components/dynamic_fields.vue';
import { makeAnalyzerEntities, makeEntities, makeSastCiConfiguration } from './helpers';
describe('AnalyzerConfiguration component', () => {
let wrapper;
const entity = {
name: 'name',
label: 'label',
description: 'description',
enabled: false,
};
let entity;
const createComponent = ({ props = {} } = {}) => {
wrapper = mount(AnalyzerConfiguration, {
......@@ -23,6 +18,10 @@ describe('AnalyzerConfiguration component', () => {
const findInputElement = () => wrapper.find('input[type="checkbox"]');
const findDynamicFields = () => wrapper.find(DynamicFields);
beforeEach(() => {
[entity] = makeAnalyzerEntities(1);
});
afterEach(() => {
wrapper.destroy();
});
......@@ -69,8 +68,8 @@ describe('AnalyzerConfiguration component', () => {
});
});
describe('configuration form', () => {
describe('when there are no SastCiConfigurationEntity', () => {
describe('child variables', () => {
describe('when there are no SastCiConfigurationEntity child variables', () => {
beforeEach(() => {
createComponent({
props: { entity },
......@@ -82,45 +81,36 @@ describe('AnalyzerConfiguration component', () => {
});
});
describe('when there are one or more SastCiConfigurationEntity', () => {
const analyzerEntity = {
...entity,
enabled: false,
configuration: [
{
defaultValue: 'defaultVal',
description: 'desc',
field: 'field',
type: 'string',
value: 'val',
label: 'label',
},
],
};
describe('when there are one or more SastCiConfigurationEntity child variables', () => {
let newEntities;
beforeEach(() => {
[entity] = makeSastCiConfiguration().analyzers.nodes;
createComponent({
props: { entity: analyzerEntity },
props: { entity },
});
});
it('it renders the nested dynamic forms', () => {
it('it renders the nested DynamicFields component', () => {
expect(findDynamicFields().exists()).toBe(true);
});
it('it emits an input event when dynamic form fields emits an input event', () => {
findDynamicFields().vm.$emit('input', analyzerEntity.configuration);
it('it emits an input event when DynamicFields emits an input event', () => {
newEntities = makeEntities(1, { field: 'new field' });
findDynamicFields().vm.$emit('input', newEntities);
const [[payload]] = wrapper.emitted('input');
expect(payload).toEqual(analyzerEntity);
expect(wrapper.emitted('input')).toEqual([
[{ ...entity, variables: { nodes: newEntities } }],
]);
});
it('passes the disabled prop to dynamic fields component', () => {
expect(findDynamicFields().props('disabled')).toBe(!analyzerEntity.enabled);
it('passes the disabled prop to DynamicFields component', () => {
expect(findDynamicFields().props('disabled')).toBe(!entity.enabled);
});
it('passes the entities prop to the dynamic fields component', () => {
expect(findDynamicFields().props('entities')).toBe(analyzerEntity.configuration);
it('passes the entities prop to the DynamicFields component', () => {
expect(findDynamicFields().props('entities')).toBe(entity.variables.nodes);
});
});
});
......
......@@ -35,8 +35,11 @@ describe('DynamicFields component', () => {
});
describe.each([true, false])('given the disabled prop is %p', disabled => {
let entities;
beforeEach(() => {
createComponent({ entities: [], disabled }, mount);
entities = makeEntities(2);
createComponent({ entities, disabled }, mount);
});
it('uses a fieldset as the root element', () => {
......@@ -47,6 +50,16 @@ describe('DynamicFields component', () => {
it(`${disabled ? 'sets' : 'does not set'} the disabled attribute on the root element`, () => {
expect('disabled' in wrapper.attributes()).toBe(disabled);
});
it('passes the disabled prop to child fields', () => {
entities.forEach((entity, i) => {
expect(
findFields()
.at(i)
.props('disabled'),
).toBe(disabled);
});
});
});
describe('given valid entities', () => {
......
......@@ -77,7 +77,7 @@ describe('FormInput component', () => {
});
describe('custom value message', () => {
describe('given the value equals the custom value', () => {
describe('given the value equals the default value', () => {
beforeEach(() => {
createComponent({
props: testProps,
......@@ -89,7 +89,7 @@ describe('FormInput component', () => {
});
});
describe('given the value differs from the custom value', () => {
describe('given the value differs from the default value', () => {
beforeEach(() => {
createComponent({
props: {
......@@ -112,6 +112,17 @@ describe('FormInput component', () => {
expect(wrapper.emitted('input')).toEqual([[testProps.defaultValue]]);
});
});
describe('disabling the input', () => {
beforeEach(() => {
wrapper.setProps({ disabled: true });
return wrapper.vm.$nextTick();
});
it('does not display the custom value message', () => {
expect(findRestoreDefaultLink().exists()).toBe(false);
});
});
});
});
......
......@@ -18,26 +18,6 @@ export const makeEntities = (count, changes) =>
...changes,
}));
/**
* Creates a mock SastCiConfiguration GraphQL object instance.
*
* @param {number} totalEntities - The total number of entities to create.
* @returns {SastCiConfiguration}
*/
export const makeSastCiConfiguration = (totalEntities = 2) => {
// Call makeEntities just once to ensure unique fields
const entities = makeEntities(totalEntities);
return {
global: {
nodes: entities.slice(0, totalEntities - 1),
},
pipeline: {
nodes: entities.slice(totalEntities - 1),
},
};
};
/**
* Creates an array of objects matching the shape of a GraphQl
* SastCiConfigurationAnalyzersEntity.
......@@ -55,3 +35,30 @@ export const makeAnalyzerEntities = (count, changes) =>
enabled: true,
...changes,
}));
/**
* Creates a mock SastCiConfiguration GraphQL object instance.
*
* @param {number} totalEntities - The total number of entities to create.
* @returns {SastCiConfiguration}
*/
export const makeSastCiConfiguration = () => {
// Call makeEntities just once to ensure unique fields
const entities = makeEntities(3);
return {
global: {
nodes: [entities.shift()],
},
pipeline: {
nodes: [entities.shift()],
},
analyzers: {
nodes: makeAnalyzerEntities(1, {
variables: {
nodes: [entities.shift()],
},
}),
},
};
};
......@@ -2,6 +2,7 @@ import {
isValidConfigurationEntity,
isValidAnalyzerEntity,
toSastCiConfigurationEntityInput,
toSastCiConfigurationAnalyzerEntityInput,
} from 'ee/security_configuration/sast/components/utils';
import { makeEntities, makeAnalyzerEntities } from './helpers';
......@@ -40,7 +41,6 @@ describe('isValidAnalyzerEntity', () => {
{},
...makeAnalyzerEntities(1, { name: undefined }),
...makeAnalyzerEntities(1, { label: undefined }),
...makeAnalyzerEntities(1, { description: undefined }),
...makeAnalyzerEntities(1, { enabled: undefined }),
...makeAnalyzerEntities(1, { enabled: '' }),
];
......@@ -55,7 +55,7 @@ describe('isValidAnalyzerEntity', () => {
});
describe('toSastCiConfigurationEntityInput', () => {
let entity = makeEntities(1);
let entity;
describe('given a SastCiConfigurationEntity', () => {
beforeEach(() => {
......@@ -71,3 +71,51 @@ describe('toSastCiConfigurationEntityInput', () => {
});
});
});
describe('toSastCiConfigurationAnalyzerEntityInput', () => {
let entity;
describe.each`
context | enabled | variables
${'a disabled entity with variables'} | ${false} | ${makeEntities(1)}
${'an enabled entity without variables'} | ${true} | ${undefined}
`('given $context', ({ enabled, variables }) => {
beforeEach(() => {
if (variables) {
[entity] = makeAnalyzerEntities(1, { enabled, variables: { nodes: variables } });
} else {
[entity] = makeAnalyzerEntities(1, { enabled });
}
});
it('returns a SastCiConfigurationAnalyzerEntityInput without variables', () => {
expect(toSastCiConfigurationAnalyzerEntityInput(entity)).toEqual({
name: entity.name,
enabled: entity.enabled,
});
});
});
describe('given an enabled entity with variables', () => {
beforeEach(() => {
[entity] = makeAnalyzerEntities(1, {
enabled: true,
variables: { nodes: makeEntities(1) },
});
});
it('returns a SastCiConfigurationAnalyzerEntityInput with variables', () => {
expect(toSastCiConfigurationAnalyzerEntityInput(entity)).toEqual({
name: entity.name,
enabled: entity.enabled,
variables: [
{
field: 'field0',
defaultValue: 'defaultValue0',
value: 'value0',
},
],
});
});
});
});
......@@ -6,6 +6,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do
let_it_be(:project) { create(:project) }
let(:project_path) { project.full_path }
let(:docs_path) { help_page_path('user/application_security/sast/index', anchor: 'configuration') }
let(:analyzers_docs_path) { help_page_path('user/application_security/sast/analyzers') }
describe '#sast_configuration_data' do
subject { helper.sast_configuration_data(project) }
......@@ -14,6 +15,7 @@ RSpec.describe Projects::Security::SastConfigurationHelper do
is_expected.to eq({
create_sast_merge_request_path: project_security_configuration_sast_path(project),
project_path: project_path,
sast_analyzers_documentation_path: analyzers_docs_path,
sast_documentation_path: docs_path,
security_configuration_path: project_security_configuration_path(project)
})
......
......@@ -22421,6 +22421,9 @@ msgstr ""
msgid "SecurityConfiguration|Available for on-demand DAST"
msgstr ""
msgid "SecurityConfiguration|By default, all analyzers are applied in order to cover all languages across your project, and only run if the language is detected in the Merge Request."
msgstr ""
msgid "SecurityConfiguration|Configure"
msgstr ""
......@@ -22454,6 +22457,9 @@ msgstr ""
msgid "SecurityConfiguration|Not enabled"
msgstr ""
msgid "SecurityConfiguration|SAST Analyzers"
msgstr ""
msgid "SecurityConfiguration|SAST Configuration"
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