Commit 75922d85 authored by Alex Buijs's avatar Alex Buijs

Test SAST entry points

Experiment for testing SAST entry points with 3 variants:
- banner
- popover_light
- popover_dark
parent 9bedc00e
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
= render "archived_notice", project: @project = render "archived_notice", project: @project
= render_if_exists "projects/marked_for_deletion_notice", project: @project = render_if_exists "projects/marked_for_deletion_notice", project: @project
= render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project = render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project
= render_if_exists 'projects/sast_entry_points', project: @project
- view_path = @project.default_view - view_path = @project.default_view
......
...@@ -214,6 +214,7 @@ module Gitlab ...@@ -214,6 +214,7 @@ module Gitlab
config.assets.precompile << "page_bundles/jira_connect.css" config.assets.precompile << "page_bundles/jira_connect.css"
config.assets.precompile << "page_bundles/jira_connect_users.css" config.assets.precompile << "page_bundles/jira_connect_users.css"
config.assets.precompile << "page_bundles/learn_gitlab.css" config.assets.precompile << "page_bundles/learn_gitlab.css"
config.assets.precompile << "page_bundles/marketing_popover.css"
config.assets.precompile << "page_bundles/members.css" config.assets.precompile << "page_bundles/members.css"
config.assets.precompile << "page_bundles/merge_conflicts.css" config.assets.precompile << "page_bundles/merge_conflicts.css"
config.assets.precompile << "page_bundles/merge_requests.css" config.assets.precompile << "page_bundles/merge_requests.css"
......
import '~/pages/projects/show/index'; import '~/pages/projects/show/index';
import { initSastEntryPointsExperiment } from 'ee/projects/sast_entry_points_experiment';
import initVueAlerts from '~/vue_alerts'; import initVueAlerts from '~/vue_alerts';
initVueAlerts(); initVueAlerts();
initSastEntryPointsExperiment();
<script>
import { GlBanner } from '@gitlab/ui';
import { I18N } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked } from '../utils';
export default {
components: {
GlBanner,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
},
onClick() {
trackCtaClicked();
},
},
i18n: I18N,
};
</script>
<template>
<gl-banner
v-if="isVisible"
:title="$options.i18n.title"
:button-text="$options.i18n.buttonText"
:button-link="sastDocumentationPath"
variant="promotion"
class="gl-my-5"
@close="onDismiss"
@primary="onClick"
>
<p>
{{ $options.i18n.bodyText }}
</p>
</gl-banner>
</template>
<script>
import { GlPopover, GlButton } from '@gitlab/ui';
import { I18N, POPOVER_TARGET } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked } from '../utils';
export default {
components: {
GlPopover,
GlButton,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
target: document.querySelector(POPOVER_TARGET),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
},
onClick() {
trackCtaClicked();
},
},
gitlabLogo: window.gon.gitlab_logo,
i18n: I18N,
};
</script>
<template>
<gl-popover
v-if="isVisible"
:target="target"
show
triggers="manual"
placement="bottomright"
offset="93"
:css-classes="['marketing-popover', 'gl-border-4', 'gl-border-t-solid']"
>
<div class="gl-display-flex gl-mt-n2">
<img :src="$options.gitlabLogo" height="24" width="24" class="gl-ml-2 gl-mr-3" />
<div>
<div
class="gl-font-weight-bold gl-font-lg gl-line-height-20 gl-text-theme-indigo-900 gl-mb-3"
>
{{ $options.i18n.title }}
</div>
<div class="gl-font-base gl-line-height-20 gl-mb-3">
{{ $options.i18n.bodyText }}
</div>
<gl-button variant="link" :href="sastDocumentationPath" @click="onClick">
{{ $options.i18n.linkText }}
</gl-button>
</div>
<gl-button
category="tertiary"
class="gl-align-self-start gl-mt-n3 gl-mr-n3"
icon="close"
data-testid="close-btn"
:aria-label="__('Close')"
@click="onDismiss"
/>
</div>
</gl-popover>
</template>
<script>
import { GlPopover, GlButton } from '@gitlab/ui';
import { I18N, POPOVER_TARGET } from '../constants';
import { isDismissed, dismiss, trackShow, trackCtaClicked } from '../utils';
export default {
components: {
GlPopover,
GlButton,
},
props: {
sastDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
isVisible: !isDismissed(),
target: document.querySelector(POPOVER_TARGET),
};
},
mounted() {
if (this.isVisible) {
trackShow();
}
},
methods: {
onDismiss() {
this.isVisible = false;
dismiss();
},
onClick() {
trackCtaClicked();
},
},
i18n: I18N,
};
</script>
<template>
<gl-popover
v-if="isVisible"
:target="target"
show
triggers="manual"
placement="bottomright"
offset="53"
>
<template #title>
<div class="gl-display-flex">
<span>
{{ $options.i18n.title }}
<gl-emoji class="gl-ml-2" data-name="raised_hands" />
</span>
<gl-button
category="tertiary"
class="gl-align-self-start close gl-opacity-10"
icon="close"
data-testid="close-btn"
:aria-label="__('Close')"
@click="onDismiss"
/>
</div>
</template>
{{ $options.i18n.bodyText }}
<div class="gl-text-right gl-font-weight-bold">
<gl-button variant="link" :href="sastDocumentationPath" @click="onClick">
{{ $options.i18n.linkText }}
</gl-button>
</div>
</gl-popover>
</template>
import { s__ } from '~/locale';
export const EXPERIMENT_NAME = 'sast_entry_points';
export const COOKIE_NAME = 'sast_entry_point_dismissed';
export const POPOVER_TARGET = '.js-sast-entry-point';
export const I18N = {
title: s__('SastEntryPoints|Catch your security vulnerabilities ahead of time!'),
bodyText: s__(
'SastEntryPoints|GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.',
),
buttonText: s__('SastEntryPoints|Learn more'),
linkText: s__('SastEntryPoints|How do I set up SAST?'),
};
import Vue from 'vue';
import Banner from './components/banner.vue';
import PopoverDark from './components/popover_dark.vue';
import PopoverLight from './components/popover_light.vue';
export const initSastEntryPointsExperiment = () => {
const el = document.querySelector('.js-sast-entry-points-experiment');
if (!el) return false;
const { variant, sastDocumentationPath } = el.dataset;
const component = {
banner: Banner,
popover_dark: PopoverDark,
popover_light: PopoverLight,
}[variant];
if (!component) return false;
return new Vue({
el,
render(h) {
return h(component, {
props: {
sastDocumentationPath,
},
});
},
});
};
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils';
import { COOKIE_NAME, EXPERIMENT_NAME } from './constants';
const tracking = new ExperimentTracking(EXPERIMENT_NAME);
export const isDismissed = () => {
return parseBoolean(getCookie(COOKIE_NAME));
};
export const dismiss = () => {
setCookie(COOKIE_NAME, 'true');
tracking.event('dismissed');
};
export const trackShow = () => {
tracking.event('show');
};
export const trackCtaClicked = () => {
tracking.event('cta_clicked');
};
@import 'page_bundles/mixins_and_variables_and_functions';
.marketing-popover {
max-width: $grid-size * 45;
border-top-color: $theme-indigo-900;
.arrow {
@include gl-mt-n2;
&::after {
border-bottom-color: $theme-indigo-900;
}
}
}
...@@ -12,6 +12,7 @@ module EE ...@@ -12,6 +12,7 @@ module EE
before_action only: :show do before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project) push_frontend_feature_flag(:cve_id_request_button, project)
enable_sast_entry_points_experiment
end end
feature_category :projects, [:restore] feature_category :projects, [:restore]
...@@ -188,5 +189,18 @@ module EE ...@@ -188,5 +189,18 @@ module EE
def log_unarchive_audit_event def log_unarchive_audit_event
log_audit_event(message: 'Project unarchived') log_audit_event(message: 'Project unarchived')
end end
def enable_sast_entry_points_experiment
return unless enable_sast_entry_points_experiment?(project)
experiment(:sast_entry_points, namespace: project.root_ancestor) do |e|
e.control {}
e.candidate(:banner) {}
e.candidate(:popover_light) {}
e.candidate(:popover_dark) {}
e.record!
end
end
end end
end end
...@@ -243,6 +243,17 @@ module EE ...@@ -243,6 +243,17 @@ module EE
project.marked_for_deletion_at.present? project.marked_for_deletion_at.present?
end end
def enable_sast_entry_points_experiment?(project)
can?(current_user, :admin_project, project) &&
!project.empty_repo? &&
!OnboardingProgress.completed?(project.root_ancestor, :security_scan_enabled)
end
def sast_entry_points_experiment_enabled?(project)
enable_sast_entry_points_experiment?(project) &&
experiment(:sast_entry_points, namespace: project.root_ancestor).variant.group == :experiment
end
private private
def remove_message_data(project) def remove_message_data(project)
......
...@@ -10,11 +10,30 @@ module EE ...@@ -10,11 +10,30 @@ module EE
end end
def extra_statistics_buttons def extra_statistics_buttons
[] [sast_anchor_data.presence].compact
end end
def approver_groups def approver_groups
::ApproverGroup.filtered_approver_groups(project.approver_groups, current_user) ::ApproverGroup.filtered_approver_groups(project.approver_groups, current_user)
end end
private
def sast_anchor_data
return unless sast_entry_points_experiment_enabled?(project)
::ProjectPresenter::AnchorData.new(
false,
statistic_icon + _('Add Security Testing'),
help_page_path('user/application_security/sast/index'),
'btn-dashed js-sast-entry-point',
nil,
nil,
{
'track-event': 'cta_clicked_button',
'track-experiment': 'sast_entry_points'
}
)
end
end end
end end
- if sast_entry_points_experiment_enabled?(project)
- variant = experiment(:sast_entry_points, namespace: project.root_ancestor).variant.name
- add_page_specific_style 'page_bundles/marketing_popover'
.js-sast-entry-points-experiment{ data: { variant: variant, sast_documentation_path: help_page_path('user/application_security/sast/index') } }
---
name: sast_entry_points
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64625
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334277
milestone: '14.1'
type: experiment
group: group::activation
default_enabled: false
...@@ -105,6 +105,28 @@ RSpec.describe ProjectsController do ...@@ -105,6 +105,28 @@ RSpec.describe ProjectsController do
it_behaves_like 'namespace storage limit alert' it_behaves_like 'namespace storage limit alert'
end end
context 'sast_entry_points experiment' do
before do
allow(controller).to receive(:enable_sast_entry_points_experiment?).with(public_project).and_return(true)
stub_experiments(sast_entry_points: :banner)
end
it 'tracks the assignment', :experiment do
expect(experiment(:sast_entry_points))
.to track(:assignment)
.with_context(namespace: public_project.namespace)
.on_next_instance
subject
end
it 'records the subject' do
expect(Experiment).to receive(:add_subject).with('sast_entry_points', variant: :experimental, subject: public_project.namespace)
subject
end
end
end end
describe 'GET edit' do describe 'GET edit' do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<gl-banner-stub
buttonlink="sast_documentation_path"
buttontext="Learn more"
class="gl-my-5"
title="Catch your security vulnerabilities ahead of time!"
variant="promotion"
>
<p>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
</p>
</gl-banner-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<div
class="gl-popover"
offset="93"
show=""
>
<div
class="gl-display-flex gl-mt-n2"
>
<img
class="gl-ml-2 gl-mr-3"
height="24"
width="24"
/>
<div>
<div
class="gl-font-weight-bold gl-font-lg gl-line-height-20 gl-text-theme-indigo-900 gl-mb-3"
>
Catch your security vulnerabilities ahead of time!
</div>
<div
class="gl-font-base gl-line-height-20 gl-mb-3"
>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
</div>
<a
class="btn btn-link btn-md gl-button"
href="sast_documentation_path"
>
<!---->
<!---->
<span
class="gl-button-text"
>
How do I set up SAST?
</span>
</a>
</div>
<button
aria-label="Close"
class="btn gl-align-self-start gl-mt-n3 gl-mr-n3 btn-default btn-md gl-button btn-default-tertiary btn-icon"
data-testid="close-btn"
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="close-icon"
role="img"
>
<use
href="#close"
/>
</svg>
<!---->
</button>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When the cookie is not set matches the snapshot 1`] = `
<div
class="gl-popover"
offset="53"
show=""
>
GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more.
<div
class="gl-text-right gl-font-weight-bold"
>
<a
class="btn btn-link btn-md gl-button"
href="sast_documentation_path"
>
<!---->
<!---->
<span
class="gl-button-text"
>
How do I set up SAST?
</span>
</a>
</div>
<div
class="gl-display-flex"
>
<span>
Catch your security vulnerabilities ahead of time!
<gl-emoji
class="gl-ml-2"
data-name="raised_hands"
/>
</span>
<button
aria-label="Close"
class="btn gl-align-self-start close gl-opacity-10 btn-default btn-md gl-button btn-default-tertiary btn-icon"
data-testid="close-btn"
type="button"
>
<!---->
<svg
aria-hidden="true"
class="gl-button-icon gl-icon s16"
data-testid="close-icon"
role="img"
>
<use
href="#close"
/>
</svg>
<!---->
</button>
</div>
</div>
`;
import { GlBanner } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Cookies from 'js-cookie';
import Banner from 'ee/projects/sast_entry_points_experiment/components/banner.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findBanner = () => wrapper.findComponent(GlBanner);
function createComponent() {
wrapper = shallowMount(Banner, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true');
createComponent();
});
it('does not render the component', () => {
expect(findBanner().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findBanner().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findBanner().attributes('buttonlink')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findBanner().vm.$emit('primary');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findBanner().vm.$emit('close');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findBanner().exists()).toBe(false);
});
});
});
import '~/commons';
import { GlPopover, GlButton } from '@gitlab/ui';
import Cookies from 'js-cookie';
import PopoverDark from 'ee/projects/sast_entry_points_experiment/components/popover_dark.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findPopover = () => wrapper.findComponent(GlPopover);
const findCtaButton = () => findPopover().findComponent(GlButton);
const findCloseButton = () => wrapper.findByTestId('close-btn');
function createComponent() {
wrapper = mountExtended(PopoverDark, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true');
createComponent();
});
it('does not render the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findPopover().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findCtaButton().attributes('href')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findCtaButton().vm.$emit('click');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
});
import '~/commons';
import { GlPopover, GlButton } from '@gitlab/ui';
import Cookies from 'js-cookie';
import PopoverLight from 'ee/projects/sast_entry_points_experiment/components/popover_light.vue';
import { COOKIE_NAME } from 'ee/projects/sast_entry_points_experiment/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ExperimentTracking from '~/experimentation/experiment_tracking';
jest.mock('~/experimentation/experiment_tracking');
let wrapper;
const sastDocumentationPath = 'sast_documentation_path';
const findPopover = () => wrapper.findComponent(GlPopover);
const findCtaButton = () => findPopover().findComponent(GlButton);
const findCloseButton = () => wrapper.findByTestId('close-btn');
function createComponent() {
wrapper = mountExtended(PopoverLight, {
propsData: { sastDocumentationPath },
});
}
afterEach(() => {
wrapper.destroy();
Cookies.remove(COOKIE_NAME);
});
describe('When the cookie is set', () => {
beforeEach(() => {
Cookies.set(COOKIE_NAME, 'true');
createComponent();
});
it('does not render the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('When the cookie is not set', () => {
beforeEach(() => {
createComponent();
});
it('renders the component', () => {
expect(findPopover().exists()).toBe(true);
});
it('tracks the show event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('show');
});
it('uses the sastDocumentationPath from the props for the button link', () => {
expect(findCtaButton().attributes('href')).toBe(sastDocumentationPath);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('When clicking the CTA button', () => {
beforeEach(() => {
findCtaButton().vm.$emit('click');
});
it('tracks the cta_clicked event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('cta_clicked');
});
});
describe('When dismissing the component', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
});
it('tracks the dismissed event', () => {
expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('dismissed');
});
it('sets a cookie', () => {
expect(Cookies.get(COOKIE_NAME)).toBe('true');
});
it('hides the component', () => {
expect(findPopover().exists()).toBe(false);
});
});
});
...@@ -413,4 +413,49 @@ RSpec.describe ProjectsHelper do ...@@ -413,4 +413,49 @@ RSpec.describe ProjectsHelper do
}) })
end end
end end
describe '#enable_sast_entry_points_experiment?' do
using RSpec::Parameterized::TableSyntax
where(
can_admin_project?: [true, false],
empty_repo?: [true, false],
sast_enabled?: [true, false]
)
with_them do
before do
allow(helper).to receive(:can?) { can_admin_project? }
allow(project).to receive(:empty_repo?) { empty_repo? }
allow(project).to receive(:scanner_enabled?).and_call_original
allow(OnboardingProgress).to receive(:completed?).with(project.root_ancestor, :security_scan_enabled) { sast_enabled? }
allow(helper).to receive(:current_user) { double }
end
subject { helper.enable_sast_entry_points_experiment?(project) }
it { is_expected.to eq(can_admin_project? && !empty_repo? && !sast_enabled?) }
end
end
describe '#sast_entry_points_experiment_enabled?' do
using RSpec::Parameterized::TableSyntax
where(
enable_sast_entry_points_experiment?: [true, false],
experiment_enabled?: [true, false]
)
with_them do
before do
allow(helper).to receive(:enable_sast_entry_points_experiment?) { enable_sast_entry_points_experiment? }
variant = experiment_enabled? ? :banner : :control
stub_experiments(sast_entry_points: variant)
end
subject { helper.sast_entry_points_experiment_enabled?(project) }
it { is_expected.to eq(enable_sast_entry_points_experiment? && experiment_enabled?) }
end
end
end end
...@@ -12,5 +12,15 @@ RSpec.describe ProjectPresenter do ...@@ -12,5 +12,15 @@ RSpec.describe ProjectPresenter do
let(:presenter) { described_class.new(project, current_user: user) } let(:presenter) { described_class.new(project, current_user: user) }
it { expect(presenter.extra_statistics_buttons).to be_empty } it { expect(presenter.extra_statistics_buttons).to be_empty }
context 'when the sast entry points experiment is enabled' do
before do
allow(presenter).to receive(:sast_entry_points_experiment_enabled?).with(project).and_return(true)
end
it 'has the sast help page button' do
expect(presenter.extra_statistics_buttons.find { |button| button[:link] == help_page_path('user/application_security/sast/index') }).not_to be_nil
end
end
end end
end end
...@@ -1903,6 +1903,9 @@ msgstr "" ...@@ -1903,6 +1903,9 @@ msgstr ""
msgid "Add README" msgid "Add README"
msgstr "" msgstr ""
msgid "Add Security Testing"
msgstr ""
msgid "Add Zoom meeting" msgid "Add Zoom meeting"
msgstr "" msgstr ""
...@@ -28543,6 +28546,18 @@ msgstr "" ...@@ -28543,6 +28546,18 @@ msgstr ""
msgid "SVG illustration" msgid "SVG illustration"
msgstr "" msgstr ""
msgid "SastEntryPoints|Catch your security vulnerabilities ahead of time!"
msgstr ""
msgid "SastEntryPoints|GitLab can scan your code for security vulnerabilities. Static Application Security Testing (SAST) helps you worry less and build more."
msgstr ""
msgid "SastEntryPoints|How do I set up SAST?"
msgstr ""
msgid "SastEntryPoints|Learn more"
msgstr ""
msgid "Satisfied" msgid "Satisfied"
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